Files
SlipItIn/SlipItIn.Server/Services/GameService.cs
Tim Krampitz af6528ee68
Some checks failed
Build / build (pull_request) Has been cancelled
Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet
Doku/Repo:
- README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben
- appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt
- appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt
- build.yml: Build-CI für master/PRs hinzugefügt
- slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel)
- .gitignore: Kommentar zu den Legacy-SQLite-Einträgen

Server:
- Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret)
- Passwort-Mindestlänge 8 Zeichen bei Registrierung
- StartGame: nur aus Lobby, mindestens 2 Spieler
- SetPlayerReady: gameId/Status geprüft
- SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert
- CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges
- ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt
- GetPlayerHand: nur Karten der aktiven Runde
- RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt
- Lobby-Code: crypto-random mit Kollisions-Retry
- EF: explizite NpgsqlDataSource-Auflösung aus DI
- Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent

Client:
- Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden)
- ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect)
- Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren
- SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts
- ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr
- LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate()
- GameBoard: lokaler 1s-Timer für RoundTimeRemaining
- Overlay: x:DataType für kompilierte Bindings, Frame→Border
- csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
2026-08-22 19:37:54 +02:00

353 lines
14 KiB
C#

using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
using System.Security.Cryptography;
namespace SlipItIn.Server.Services;
public class GameService : IGameService
{
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly ILogger<GameService> _logger;
public GameService(IDbContextFactory<SlipItInDbContext> contextFactory, ILogger<GameService> logger)
{
_contextFactory = contextFactory;
_logger = logger;
}
public async Task<Game> CreateGameAsync(int hostUserId, string? connectionId = null)
{
using var context = _contextFactory.CreateDbContext();
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found");
var lobbyCode = await GenerateUniqueLobbyCodeAsync(context);
var game = new Game
{
LobbyCode = lobbyCode,
HostId = host.Id,
Status = GameStatus.Lobby
};
// Host wird automatisch als erster Spieler hinzugefügt
var hostPlayer = new Player { UserId = host.Id, ConnectionId = connectionId };
// Spieler direkt zur Liste des Spiels hinzufügen
game.Players.Add(hostPlayer);
// Es reicht, nur das Game hinzuzufügen — EF Core fügt den Player automatisch mit hinzu
context.Games.Add(game);
await context.SaveChangesAsync();
return game;
}
public async Task<Game> JoinGameAsync(string lobbyCode, int userId, string? connectionId = null)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games
.Include(g => g.Players)
.FirstOrDefaultAsync(g => g.LobbyCode == lobbyCode && g.Status == GameStatus.Lobby) ?? 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.Id == userId) ?? throw new InvalidOperationException("User not found");
// Prüfen ob User bereits im Spiel ist
if (game.Players.Any(p => p.UserId == userId))
throw new InvalidOperationException("User already in game");
var player = new Player { UserId = user.Id, GameId = game.Id, ConnectionId = connectionId };
context.Players.Add(player);
await context.SaveChangesAsync();
return game;
}
public async Task<Game> StartGameAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games
.Include(g => g.Players)
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.Lobby)
throw new InvalidOperationException("Game has already started or ended");
if (game.Players.Count < 2)
throw new InvalidOperationException("At least 2 players are required to start the game");
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)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.Lobby)
throw new InvalidOperationException("Game is not in lobby status");
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId)
?? throw new InvalidOperationException("Player not found in this game");
player.IsReady = true;
await context.SaveChangesAsync();
return game;
}
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
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() ?? throw new InvalidOperationException("No active round found");
var allPhrases = await context.Phrases.Where(p => p.IsActive).ToListAsync();
if (allPhrases.Count < 5) throw new InvalidOperationException("Not enough phrases in database");
var dealtCards = new List<PlayerCard>();
foreach (var player in players)
{
var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).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;
currentRound.StartedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return dealtCards;
}
public async Task<bool> SubmitSlipAsync(int gameId, int playerId, int cardId)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.InProgress)
throw new InvalidOperationException("Game is not in progress");
var card = await context.PlayerCards
.Include(pc => pc.GameRound)
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game");
if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active");
if (card.IsUsed) throw new InvalidOperationException("Card has already been used");
card.IsUsed = true;
await context.SaveChangesAsync();
return true;
}
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId)
{
using var context = _contextFactory.CreateDbContext();
var currentRound = await context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
var targetCard = await context.PlayerCards
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
if (targetCard.GameRoundId != currentRound.Id)
throw new InvalidOperationException("Card does not belong to the active round");
if (targetCard.PlayerId == challengingPlayerId)
throw new InvalidOperationException("Cannot challenge your own card");
if (!targetCard.IsUsed)
throw new InvalidOperationException("Card has not been played yet");
var pendingExists = await context.SlipChallenges
.AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
if (pendingExists)
throw new InvalidOperationException("A challenge for this card is already pending");
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)
{
using var context = _contextFactory.CreateDbContext();
var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard)
.Include(sc => sc.ChallengingPlayer)
.Include(sc => sc.TargetPlayer)
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
if (challenge.Status != ChallengeStatus.Pending)
throw new InvalidOperationException("Challenge has already been resolved");
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
if (approved)
{
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
challenge.ChallengingPlayer.Score += 1;
challenge.TargetPlayer.FailedSlips += 1;
}
else
{
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
challenge.TargetPlayer.SuccessfulSlips += 1;
challenge.TargetPlayer.Score += 1;
challenge.ChallengingPlayer.FailedSlips += 1;
}
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
challenge.ResolvedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return challenge;
}
public async Task<GameStateDto> GetGameStateAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games
.Include(g => g.Players)
.ThenInclude(p => p.User)
.Include(g => g.Players)
.ThenInclude(p => p.Cards)
.Include(g => g.Rounds)
.FirstOrDefaultAsync(g => g.Id == gameId) ?? 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,
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
}).ToList();
var currentRound = game.Rounds
.OrderByDescending(r => r.RoundNumber)
.FirstOrDefault();
var roundDurationSeconds = Math.Clamp(game.RoundDurationSeconds, 30, 60);
var roundTimeRemaining = roundDurationSeconds;
if (currentRound?.Status == RoundStatus.Active)
{
var elapsedSeconds = (int)(DateTime.UtcNow - currentRound.StartedAt).TotalSeconds;
roundTimeRemaining = Math.Max(0, roundDurationSeconds - elapsedSeconds);
}
return new GameStateDto
{
GameId = game.Id,
LobbyCode = game.LobbyCode,
Status = game.Status.ToString(),
Players = playerInfos,
CurrentRound = currentRound?.RoundNumber ?? 0,
RoundTimeRemaining = roundTimeRemaining
};
}
public async Task<PlayerHandDto> GetPlayerHandAsync(int playerId)
{
using var context = _contextFactory.CreateDbContext();
// Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand
// über Runden hinweg unbegrenzt und enthält veraltete Karten.
var activeRoundIds = await context.GameRounds
.Where(gr => gr.Status == RoundStatus.Active &&
gr.Game.Players.Any(p => p.Id == playerId))
.Select(gr => gr.Id)
.ToListAsync();
var cards = await context.PlayerCards
.Include(pc => pc.Phrase)
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
.Select(pc => new PlayerCardDto
{
CardId = pc.Id,
PhraseId = pc.PhraseId,
Text = pc.Phrase.Text,
IsUsed = pc.IsUsed
})
.ToListAsync();
return new PlayerHandDto
{
PlayerId = playerId,
Cards = cards
};
}
public async Task<Game> GetGameAsync(int gameId)
{
using var context = _contextFactory.CreateDbContext();
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
}
private static string GenerateLobbyCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var buffer = new char[6];
for (var i = 0; i < buffer.Length; i++)
buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
return new string(buffer);
}
private static async Task<string> GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10)
{
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
var code = GenerateLobbyCode();
var exists = await context.Games.AnyAsync(g => g.LobbyCode == code);
if (!exists)
return code;
}
throw new InvalidOperationException("Could not generate a unique lobby code");
}
}