Files
SlipItIn/SlipItIn.Server/Services/GameService.cs
Tim Krampitz 010e844976
All checks were successful
Build / build (push) Successful in 2m19s
Slip- und Beschuldigungslogik überarbeitet
Slips werden jetzt 30 Sekunden vorgehalten und anschließend automatisch abgeschlossen; direkte Spieler-Beschuldigungen, Win-Status, SignalR-Events, MAUI-UI, EF-Mapping/Migrationen sowie Tests wurden an die neue Spiellogik angepasst.
2026-10-04 19:14:16 +02:00

555 lines
21 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
};
var hostPlayer = new Player { UserId = host.Id, ConnectionId = connectionId };
game.Players.Add(hostPlayer);
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");
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");
if (game.Players.Any(player => !player.IsReady))
throw new InvalidOperationException("All players must be ready 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");
// Karte wird für 30 Sekunden vorgehalten
card.SlippedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return true;
}
public async Task<bool> CompleteSlipAsync(int gameId, int cardId, bool force = false)
{
using var context = _contextFactory.CreateDbContext();
var card = await context.PlayerCards
.Include(pc => pc.GameRound)
.ThenInclude(gr => gr.Game)
.Include(pc => pc.Player)
.FirstOrDefaultAsync(pc => pc.Id == cardId);
if (card == null || card.IsUsed || !card.SlippedAt.HasValue)
return false;
if (card.GameRound.GameId != gameId || card.GameRound.Status != RoundStatus.Active)
return false;
if (!force)
{
var elapsed = (DateTime.UtcNow - card.SlippedAt.Value).TotalSeconds;
if (elapsed < 30)
return false;
}
// Falls eine Beschuldigung gegen diesen Spieler aussteht, kann der Slip nicht abgeschlossen werden
var hasPendingChallenge = await context.SlipChallenges
.AnyAsync(c => c.GameRoundId == card.GameRoundId &&
c.TargetPlayerId == card.PlayerId &&
c.Status == ChallengeStatus.Pending);
if (hasPendingChallenge)
return false;
card.IsUsed = true;
card.SlippedAt = null;
card.Player.SuccessfulSlips += 1;
card.Player.Score += 1;
// Prüfung: Wenn der Spieler keine ungespielten Karten mehr hat, hat er gewonnen!
var remainingCards = await context.PlayerCards
.CountAsync(c => c.PlayerId == card.PlayerId && c.GameRoundId == card.GameRoundId && !c.IsUsed && c.Id != card.Id);
if (remainingCards == 0)
{
var game = card.GameRound.Game;
game.Status = GameStatus.Completed;
game.EndedAt = DateTime.UtcNow;
game.WinnerPlayerId = card.PlayerId;
card.GameRound.Status = RoundStatus.Completed;
card.GameRound.EndedAt = DateTime.UtcNow;
}
await context.SaveChangesAsync();
return true;
}
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetPlayerId)
{
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 challengerBelongsToGame = await context.Players
.AnyAsync(p => p.Id == challengingPlayerId && p.GameId == gameId);
if (!challengerBelongsToGame)
throw new UnauthorizedAccessException("Challenger does not belong to this game");
var targetPlayer = await context.Players
.FirstOrDefaultAsync(p => p.Id == targetPlayerId && p.GameId == gameId)
?? throw new InvalidOperationException("Target player not found in this game");
if (targetPlayerId == challengingPlayerId)
throw new InvalidOperationException("Cannot challenge yourself");
// Eventuell aktuell vorgehaltene Karte finden
var slippingCard = await context.PlayerCards
.Where(pc => pc.PlayerId == targetPlayerId && pc.GameRoundId == currentRound.Id && pc.SlippedAt.HasValue && !pc.IsUsed)
.OrderByDescending(pc => pc.SlippedAt)
.FirstOrDefaultAsync();
var pendingExists = await context.SlipChallenges
.AnyAsync(c => c.GameRoundId == currentRound.Id &&
c.TargetPlayerId == targetPlayerId &&
c.Status == ChallengeStatus.Pending);
if (pendingExists)
throw new InvalidOperationException("A challenge for this player is already pending");
var challenge = new SlipChallenge
{
GameRoundId = currentRound.Id,
ChallengingPlayerId = challengingPlayerId,
TargetPlayerId = targetPlayerId,
TargetCardId = slippingCard?.Id,
Status = ChallengeStatus.Pending
};
context.SlipChallenges.Add(challenge);
await context.SaveChangesAsync();
return challenge;
}
public async Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved, int? discardCardId = null)
{
using var context = _contextFactory.CreateDbContext();
var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound)
.ThenInclude(gr => gr.Game)
.Include(sc => sc.ChallengingPlayer)
.Include(sc => sc.TargetPlayer)
.ThenInclude(p => p.Cards)
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
if (challenge.Status != ChallengeStatus.Pending)
throw new InvalidOperationException("Challenge has already been resolved");
if (approved)
{
// Beschuldigung angenommen:
// 1. Beschuldiger erhält Punkt
challenge.ChallengingPlayer.Score += 1;
challenge.TargetPlayer.FailedSlips += 1;
// 2. Vorgehaltene Karte zählt nicht!
var slippingCards = challenge.TargetPlayer.Cards
.Where(c => c.GameRoundId == challenge.GameRoundId && c.SlippedAt.HasValue && !c.IsUsed)
.ToList();
// 3. Eine Karte ablegen
PlayerCard? cardToDiscard = null;
if (discardCardId.HasValue)
{
cardToDiscard = challenge.TargetPlayer.Cards
.FirstOrDefault(c => c.Id == discardCardId.Value && !c.IsUsed);
}
cardToDiscard ??= slippingCards.FirstOrDefault()
?? challenge.TargetPlayer.Cards.FirstOrDefault(c => c.GameRoundId == challenge.GameRoundId && !c.IsUsed);
if (cardToDiscard != null)
{
cardToDiscard.IsUsed = true;
cardToDiscard.SlippedAt = null;
}
// Andere vorgehaltene Slips zurücksetzen ("zählt nicht")
foreach (var slipping in slippingCards.Where(c => c.Id != cardToDiscard?.Id))
{
slipping.SlippedAt = null;
}
// 4. Zwei neue Karten ziehen
var heldPhraseIds = challenge.TargetPlayer.Cards.Select(c => c.PhraseId).ToHashSet();
var availablePhrases = await context.Phrases
.Where(p => p.IsActive && !heldPhraseIds.Contains(p.Id))
.ToListAsync();
if (availablePhrases.Count < 2)
{
availablePhrases = await context.Phrases.Where(p => p.IsActive).ToListAsync();
}
var newPhrases = availablePhrases
.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.Take(2)
.ToList();
foreach (var phrase in newPhrases)
{
var newCard = new PlayerCard
{
PlayerId = challenge.TargetPlayerId,
PhraseId = phrase.Id,
GameRoundId = challenge.GameRoundId,
IsUsed = false
};
context.PlayerCards.Add(newCard);
}
}
else
{
// Anfechten: "dann wird die Beschuldigung zurückgenommen."
// Status wird auf Rejected gesetzt, keine Strafen.
}
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();
await CompleteExpiredSlipsInternalAsync(context, gameId);
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)
.Include(g => g.WinnerPlayer)
.ThenInclude(wp => wp!.User)
.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)
}).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);
}
string? winnerUsername = game.WinnerPlayer?.User?.Username;
if (winnerUsername == null && game.WinnerPlayerId.HasValue)
{
winnerUsername = game.Players.FirstOrDefault(p => p.Id == game.WinnerPlayerId.Value)?.User?.Username;
}
return new GameStateDto
{
GameId = game.Id,
LobbyCode = game.LobbyCode,
Status = game.Status.ToString(),
Players = playerInfos,
HostUserId = game.HostId,
CurrentRound = currentRound?.RoundNumber ?? 0,
RoundTimeRemaining = roundTimeRemaining,
WinnerPlayerId = game.WinnerPlayerId,
WinnerUsername = winnerUsername
};
}
public async Task<PlayerHandDto> GetPlayerHandAsync(int playerId)
{
using var context = _contextFactory.CreateDbContext();
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player != null)
{
await CompleteExpiredSlipsInternalAsync(context, player.GameId);
}
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))
.ToListAsync();
var cardDtos = cards.Select(pc =>
{
int remainingHold = 0;
if (pc.SlippedAt.HasValue && !pc.IsUsed)
{
var elapsed = (int)(DateTime.UtcNow - pc.SlippedAt.Value).TotalSeconds;
remainingHold = Math.Max(0, 30 - elapsed);
}
return new PlayerCardDto
{
CardId = pc.Id,
PhraseId = pc.PhraseId,
Text = pc.Phrase.Text,
IsUsed = pc.IsUsed,
SlippedAt = pc.SlippedAt,
SlippedHoldSecondsRemaining = remainingHold
};
}).ToList();
return new PlayerHandDto
{
PlayerId = playerId,
Cards = cardDtos
};
}
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 async Task CompleteExpiredSlipsInternalAsync(SlipItInDbContext context, int gameId)
{
var expiredThreshold = DateTime.UtcNow.AddSeconds(-30);
var candidateCards = await context.PlayerCards
.Include(pc => pc.GameRound)
.ThenInclude(gr => gr.Game)
.Include(pc => pc.Player)
.Where(pc => pc.GameRound.GameId == gameId &&
pc.GameRound.Status == RoundStatus.Active &&
!pc.IsUsed &&
pc.SlippedAt.HasValue &&
pc.SlippedAt.Value <= expiredThreshold)
.ToListAsync();
if (candidateCards.Count == 0)
return;
var pendingChallengeTargetIds = await context.SlipChallenges
.Where(sc => sc.GameRound.GameId == gameId && sc.Status == ChallengeStatus.Pending)
.Select(sc => sc.TargetPlayerId)
.Distinct()
.ToListAsync();
bool stateChanged = false;
foreach (var card in candidateCards)
{
if (pendingChallengeTargetIds.Contains(card.PlayerId))
continue;
card.IsUsed = true;
card.SlippedAt = null;
card.Player.SuccessfulSlips += 1;
card.Player.Score += 1;
stateChanged = true;
var remainingCount = await context.PlayerCards
.CountAsync(c => c.PlayerId == card.PlayerId && c.GameRoundId == card.GameRoundId && !c.IsUsed && c.Id != card.Id);
if (remainingCount == 0)
{
var game = card.GameRound.Game;
if (game.Status != GameStatus.Completed)
{
game.Status = GameStatus.Completed;
game.EndedAt = DateTime.UtcNow;
game.WinnerPlayerId = card.PlayerId;
card.GameRound.Status = RoundStatus.Completed;
card.GameRound.EndedAt = DateTime.UtcNow;
}
}
}
if (stateChanged)
{
await context.SaveChangesAsync();
}
}
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");
}
}