Slip- und Beschuldigungslogik überarbeitet
All checks were successful
Build / build (push) Successful in 2m19s
All checks were successful
Build / build (push) Successful in 2m19s
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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SlipItIn.Server.Data;
|
||||
using SlipItIn.Shared.DTOs;
|
||||
using SlipItIn.Shared.Models;
|
||||
@@ -30,18 +30,11 @@ public class GameService : IGameService
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,7 +49,6 @@ public class GameService : IGameService
|
||||
|
||||
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");
|
||||
|
||||
@@ -165,13 +157,69 @@ public class GameService : IGameService
|
||||
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;
|
||||
// Karte wird für 30 Sekunden vorgehalten
|
||||
card.SlippedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<SlipChallenge> CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId)
|
||||
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();
|
||||
|
||||
@@ -184,27 +232,32 @@ public class GameService : IGameService
|
||||
if (!challengerBelongsToGame)
|
||||
throw new UnauthorizedAccessException("Challenger does not belong to this game");
|
||||
|
||||
var targetCard = await context.PlayerCards
|
||||
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
|
||||
var targetPlayer = await context.Players
|
||||
.FirstOrDefaultAsync(p => p.Id == targetPlayerId && p.GameId == gameId)
|
||||
?? throw new InvalidOperationException("Target player not found in this game");
|
||||
|
||||
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");
|
||||
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.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
|
||||
.AnyAsync(c => c.GameRoundId == currentRound.Id &&
|
||||
c.TargetPlayerId == targetPlayerId &&
|
||||
c.Status == ChallengeStatus.Pending);
|
||||
if (pendingExists)
|
||||
throw new InvalidOperationException("A challenge for this card is already pending");
|
||||
throw new InvalidOperationException("A challenge for this player is already pending");
|
||||
|
||||
var challenge = new SlipChallenge
|
||||
{
|
||||
GameRoundId = currentRound.Id,
|
||||
ChallengingPlayerId = challengingPlayerId,
|
||||
TargetPlayerId = targetCard.PlayerId,
|
||||
TargetCardId = targetCardId,
|
||||
TargetPlayerId = targetPlayerId,
|
||||
TargetCardId = slippingCard?.Id,
|
||||
Status = ChallengeStatus.Pending
|
||||
};
|
||||
|
||||
@@ -214,36 +267,88 @@ public class GameService : IGameService
|
||||
return challenge;
|
||||
}
|
||||
|
||||
public async Task<SlipChallenge> ResolveChallengeAsync(int challengeId, bool approved)
|
||||
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)
|
||||
.Include(sc => sc.TargetCard)
|
||||
.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");
|
||||
|
||||
// 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;
|
||||
// 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
|
||||
{
|
||||
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
|
||||
challenge.TargetPlayer.SuccessfulSlips += 1;
|
||||
challenge.TargetPlayer.Score += 1;
|
||||
challenge.ChallengingPlayer.FailedSlips += 1;
|
||||
// Anfechten: "dann wird die Beschuldigung zurückgenommen."
|
||||
// Status wird auf Rejected gesetzt, keine Strafen.
|
||||
}
|
||||
|
||||
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
|
||||
@@ -257,12 +362,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
await CompleteExpiredSlipsInternalAsync(context, gameId);
|
||||
|
||||
var game = await context.Games
|
||||
.Include(g => g.Players)
|
||||
.ThenInclude(p => p.User)
|
||||
.ThenInclude(p => p.User)
|
||||
.Include(g => g.Players)
|
||||
.ThenInclude(p => p.Cards)
|
||||
.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
|
||||
@@ -271,7 +380,7 @@ public class GameService : IGameService
|
||||
Username = p.User.Username,
|
||||
Score = p.Score,
|
||||
IsReady = p.IsReady,
|
||||
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
|
||||
CardCount = p.Cards.Count(c => !c.IsUsed)
|
||||
}).ToList();
|
||||
|
||||
var currentRound = game.Rounds
|
||||
@@ -287,6 +396,12 @@ public class GameService : IGameService
|
||||
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,
|
||||
@@ -295,7 +410,9 @@ public class GameService : IGameService
|
||||
Players = playerInfos,
|
||||
HostUserId = game.HostId,
|
||||
CurrentRound = currentRound?.RoundNumber ?? 0,
|
||||
RoundTimeRemaining = roundTimeRemaining
|
||||
RoundTimeRemaining = roundTimeRemaining,
|
||||
WinnerPlayerId = game.WinnerPlayerId,
|
||||
WinnerUsername = winnerUsername
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,8 +420,12 @@ public class GameService : IGameService
|
||||
{
|
||||
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 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))
|
||||
@@ -314,19 +435,32 @@ public class GameService : IGameService
|
||||
var cards = await context.PlayerCards
|
||||
.Include(pc => pc.Phrase)
|
||||
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
|
||||
.Select(pc => new PlayerCardDto
|
||||
.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
|
||||
})
|
||||
.ToListAsync();
|
||||
IsUsed = pc.IsUsed,
|
||||
SlippedAt = pc.SlippedAt,
|
||||
SlippedHoldSecondsRemaining = remainingHold
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new PlayerHandDto
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Cards = cards
|
||||
Cards = cardDtos
|
||||
};
|
||||
}
|
||||
|
||||
@@ -337,6 +471,65 @@ public class GameService : IGameService
|
||||
?? 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";
|
||||
|
||||
Reference in New Issue
Block a user