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.
555 lines
21 KiB
C#
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");
|
|
}
|
|
}
|