From 010e8449761a21902cfd80bfe053b40641775c37 Mon Sep 17 00:00:00 2001 From: Tim Krampitz Date: Sun, 4 Oct 2026 19:14:16 +0200 Subject: [PATCH] =?UTF-8?q?Slip-=20und=20Beschuldigungslogik=20=C3=BCberar?= =?UTF-8?q?beitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- SlipItIn.Server.Tests/GameServiceTests.cs | 114 ++++- SlipItIn.Server/Data/SlipItInDbContext.cs | 7 + .../Data/SlipItInDbContextFactory.cs | 14 + SlipItIn.Server/Hubs/GameHub.cs | 153 ++++-- ...20261004162039_UpdateGameRules.Designer.cs | 446 ++++++++++++++++++ .../20261004162039_UpdateGameRules.cs | 78 +++ .../SlipItInDbContextModelSnapshot.cs | 22 +- SlipItIn.Server/Services/GameService.cs | 283 +++++++++-- SlipItIn.Server/Services/IGameService.cs | 5 +- SlipItIn.Shared/DTOs/GameStateDto.cs | 52 +- SlipItIn.Shared/Models/Game.cs | 2 + SlipItIn.Shared/Models/PlayerCard.cs | 1 + SlipItIn.Shared/Models/SlipChallenge.cs | 8 +- .../Services/Interfaces/ISignalRService.cs | 3 +- SlipItIn/Services/SignalRService.cs | 27 +- SlipItIn/ViewModels/GameBoardViewModel.cs | 130 ++++- .../Views/ChallengeNotificationOverlay.xaml | 34 +- SlipItIn/Views/GameBoardPage.xaml | 59 ++- 18 files changed, 1272 insertions(+), 166 deletions(-) create mode 100644 SlipItIn.Server/Data/SlipItInDbContextFactory.cs create mode 100644 SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.Designer.cs create mode 100644 SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.cs diff --git a/SlipItIn.Server.Tests/GameServiceTests.cs b/SlipItIn.Server.Tests/GameServiceTests.cs index f9fab2a..e998f95 100644 --- a/SlipItIn.Server.Tests/GameServiceTests.cs +++ b/SlipItIn.Server.Tests/GameServiceTests.cs @@ -244,7 +244,7 @@ public class GameServiceTests // ---------- SubmitSlipAsync ---------- [Fact] - public async Task SubmitSlipAsync_MarksCardAsUsed() + public async Task SubmitSlipAsync_HoldsCardFor30Seconds_ThenCompletes() { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db); @@ -260,7 +260,15 @@ public class GameServiceTests Assert.True(result); using var verify = _factory.CreateDbContext(); - Assert.True(verify.PlayerCards.Single(c => c.Id == card.Id).IsUsed); + var verifyCard = verify.PlayerCards.Single(c => c.Id == card.Id); + Assert.NotNull(verifyCard.SlippedAt); + Assert.False(verifyCard.IsUsed); // Für 30 Sekunden vorgehalten! + + // CompleteSlipAsync vollendet nach Ablauf der Zeit + var completed = await _sut.CompleteSlipAsync(game.Id, card.Id, force: true); + Assert.True(completed); + using var verify2 = _factory.CreateDbContext(); + Assert.True(verify2.PlayerCards.Single(c => c.Id == card.Id).IsUsed); } [Fact] @@ -307,11 +315,11 @@ public class GameServiceTests var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; db.GameRounds.Add(round); var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); - var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true }; + var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = false, SlippedAt = DateTime.UtcNow }; db.PlayerCards.Add(card); await db.SaveChangesAsync(); - var challenge = await _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id); + var challenge = await _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, otherPlayer.Id); Assert.Equal(round.Id, challenge.GameRoundId); Assert.Equal(hostPlayer.Id, challenge.ChallengingPlayerId); @@ -321,9 +329,37 @@ public class GameServiceTests } [Fact] - public async Task CreateChallengeAsync_Throws_WhenTargetCardNotFound() + public async Task CreateChallengeAsync_ChallengesPlayerDirectly() { - await Assert.ThrowsAsync(() => _sut.CreateChallengeAsync(1, 1, 999)); + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + await db.SaveChangesAsync(); + + var challenge = await _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, otherPlayer.Id); + + Assert.Equal(round.Id, challenge.GameRoundId); + Assert.Equal(hostPlayer.Id, challenge.ChallengingPlayerId); + Assert.Equal(otherPlayer.Id, challenge.TargetPlayerId); + Assert.Equal(ChallengeStatus.Pending, challenge.Status); + } + + [Fact] + public async Task CreateChallengeAsync_Throws_WhenTargetPlayerNotFound() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, 999)); } [Fact] @@ -331,7 +367,10 @@ public class GameServiceTests { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); var (game, hostPlayer) = TestDbHelper.SeedGame(db, host); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Waiting }; db.GameRounds.Add(round); @@ -339,7 +378,7 @@ public class GameServiceTests db.PlayerCards.Add(card); await db.SaveChangesAsync(); - await Assert.ThrowsAsync(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, card.Id)); + await Assert.ThrowsAsync(() => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id, otherPlayer.Id)); } [Fact] @@ -361,7 +400,7 @@ public class GameServiceTests // outsider ist nicht in diesem Spiel und versucht eine Challenge anzulegen await Assert.ThrowsAsync( - () => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id + 1000, card.Id)); + () => _sut.CreateChallengeAsync(game.Id, hostPlayer.Id + 1000, otherPlayer.Id)); } [Fact] @@ -382,15 +421,15 @@ public class GameServiceTests db.PlayerCards.Add(cardB); await db.SaveChangesAsync(); - // hostPlayerA gehört zu gameA, versucht aber eine Karte aus gameB zu challengen + // hostPlayerA gehört zu gameA, versucht aber einen Spieler aus gameB zu challengen await Assert.ThrowsAsync( - () => _sut.CreateChallengeAsync(gameB.Id, hostPlayerA.Id, cardB.Id)); + () => _sut.CreateChallengeAsync(gameB.Id, hostPlayerA.Id, otherPlayerB.Id)); } // ---------- ResolveChallengeAsync ---------- [Fact] - public async Task ResolveChallengeAsync_Approved_TransfersCardToChallenger() + public async Task ResolveChallengeAsync_Approved_DiscardsCardAndDrawsTwoNewCards() { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); @@ -400,8 +439,8 @@ public class GameServiceTests db.Players.Add(otherPlayer); var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; db.GameRounds.Add(round); - var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); - var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true }; + var phrases = TestDbHelper.SeedPhrases(db, 5); + var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrases[0].Id, GameRoundId = round.Id, IsUsed = false, SlippedAt = DateTime.UtcNow }; db.PlayerCards.Add(card); var challenge = new SlipChallenge { @@ -418,13 +457,19 @@ public class GameServiceTests Assert.Equal(ChallengeStatus.Approved, result.Status); Assert.NotNull(result.ResolvedAt); - // Berechtigte Beschuldigung: Karte geht an den Beschuldiger using var verify = _factory.CreateDbContext(); - Assert.Equal(hostPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId); + // Vorgehaltene Karte zählt nicht und wird abgelegt (IsUsed = true) + Assert.True(verify.PlayerCards.Single(c => c.Id == card.Id).IsUsed); + // Beschuldigter erhält 2 neue Karten (insgesamt 3 Karten für otherPlayer) + Assert.Equal(3, verify.PlayerCards.Count(c => c.PlayerId == otherPlayer.Id)); + // 2 davon aktiv + Assert.Equal(2, verify.PlayerCards.Count(c => c.PlayerId == otherPlayer.Id && !c.IsUsed)); + // Challenger erhält +1 Score + Assert.Equal(1, verify.Players.Single(p => p.Id == hostPlayer.Id).Score); } [Fact] - public async Task ResolveChallengeAsync_Rejected_KeepsCardWithTargetPlayer() + public async Task ResolveChallengeAsync_Rejected_WithdrawsChallengeWithoutPenalty() { using var db = _factory.CreateDbContext(); var host = TestDbHelper.SeedUser(db, "host", "host@t.local"); @@ -434,8 +479,8 @@ public class GameServiceTests db.Players.Add(otherPlayer); var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; db.GameRounds.Add(round); - var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); - var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, IsUsed = true }; + var phrases = TestDbHelper.SeedPhrases(db, 3); + var card = new PlayerCard { PlayerId = otherPlayer.Id, PhraseId = phrases[0].Id, GameRoundId = round.Id, IsUsed = false, SlippedAt = DateTime.UtcNow }; db.PlayerCards.Add(card); var challenge = new SlipChallenge { @@ -452,9 +497,11 @@ public class GameServiceTests Assert.Equal(ChallengeStatus.Rejected, result.Status); Assert.NotNull(result.ResolvedAt); - // Falsche Beschuldigung: Karte bleibt beim Beschuldigten using var verify = _factory.CreateDbContext(); - Assert.Equal(otherPlayer.Id, verify.PlayerCards.Single(c => c.Id == card.Id).PlayerId); + // Keine Karten abgelegt oder neu gezogen + Assert.Single(verify.PlayerCards.Where(c => c.PlayerId == otherPlayer.Id)); + Assert.False(verify.PlayerCards.Single(c => c.Id == card.Id).IsUsed); + Assert.Equal(0, verify.Players.Single(p => p.Id == hostPlayer.Id).Score); } [Fact] @@ -463,6 +510,33 @@ public class GameServiceTests await Assert.ThrowsAsync(() => _sut.ResolveChallengeAsync(999, true)); } + // ---------- Win Condition (Point 3) ---------- + + [Fact] + public async Task CompleteSlipAsync_PlayerWins_WhenNoCardsRemain() + { + using var db = _factory.CreateDbContext(); + var host = TestDbHelper.SeedUser(db); + var other = TestDbHelper.SeedUser(db, "other", "other@t.local"); + var (game, hostPlayer) = TestDbHelper.SeedGame(db, host, GameStatus.InProgress); + var otherPlayer = new Player { UserId = other.Id, GameId = game.Id }; + db.Players.Add(otherPlayer); + var round = new GameRound { GameId = game.Id, RoundNumber = 1, Status = RoundStatus.Active }; + db.GameRounds.Add(round); + var phrase = TestDbHelper.SeedPhrases(db, 1).Single(); + var card = new PlayerCard { PlayerId = hostPlayer.Id, PhraseId = phrase.Id, GameRoundId = round.Id, SlippedAt = DateTime.UtcNow.AddSeconds(-31) }; + db.PlayerCards.Add(card); + await db.SaveChangesAsync(); + + var completed = await _sut.CompleteSlipAsync(game.Id, card.Id); + + Assert.True(completed); + using var verify = _factory.CreateDbContext(); + var verifyGame = verify.Games.Single(g => g.Id == game.Id); + Assert.Equal(GameStatus.Completed, verifyGame.Status); + Assert.Equal(hostPlayer.Id, verifyGame.WinnerPlayerId); + } + // ---------- GetGameStateAsync ---------- [Fact] diff --git a/SlipItIn.Server/Data/SlipItInDbContext.cs b/SlipItIn.Server/Data/SlipItInDbContext.cs index facaa9f..948a1c1 100644 --- a/SlipItIn.Server/Data/SlipItInDbContext.cs +++ b/SlipItIn.Server/Data/SlipItInDbContext.cs @@ -33,6 +33,12 @@ public class SlipItInDbContext : DbContext .WithMany() .HasForeignKey(g => g.HostId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(g => g.WinnerPlayer) + .WithMany() + .HasForeignKey(g => g.WinnerPlayerId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); // Player modelBuilder.Entity() @@ -92,6 +98,7 @@ public class SlipItInDbContext : DbContext .HasOne(sc => sc.TargetCard) .WithMany() .HasForeignKey(sc => sc.TargetCardId) + .IsRequired(false) .OnDelete(DeleteBehavior.Restrict); } } diff --git a/SlipItIn.Server/Data/SlipItInDbContextFactory.cs b/SlipItIn.Server/Data/SlipItInDbContextFactory.cs new file mode 100644 index 0000000..d335ead --- /dev/null +++ b/SlipItIn.Server/Data/SlipItInDbContextFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace SlipItIn.Server.Data; + +public class SlipItInDbContextFactory : IDesignTimeDbContextFactory +{ + public SlipItInDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=slipitindb;Username=postgres;Password=postgres"); + return new SlipItInDbContext(optionsBuilder.Options); + } +} diff --git a/SlipItIn.Server/Hubs/GameHub.cs b/SlipItIn.Server/Hubs/GameHub.cs index f76ac37..9f01a48 100644 --- a/SlipItIn.Server/Hubs/GameHub.cs +++ b/SlipItIn.Server/Hubs/GameHub.cs @@ -242,6 +242,35 @@ public class GameHub : Hub Success = result }); await Clients.Caller.SendAsync("PlayerHandUpdated", hand); + + // Nach 30 Sekunden Vorhaltezeit automatisch vollenden (falls keine Beschuldigung dazwischen kam) + _ = Task.Run(async () => + { + try + { + // Kleiner Puffer, da Task.Delay minimal früher zurückkehren kann als die 30s-Prüfung + await Task.Delay(TimeSpan.FromSeconds(30.5)); + var completed = await _gameService.CompleteSlipAsync(gameId, cardId); + if (completed) + { + var updatedHand = await _gameService.GetPlayerHandAsync(playerId); + var updatedGameState = await _gameService.GetGameStateAsync(gameId); + + using var db = _contextFactory.CreateDbContext(); + var targetP = await db.Players.FindAsync(playerId); + if (targetP?.ConnectionId != null) + { + await Clients.Client(targetP.ConnectionId).SendAsync("PlayerHandUpdated", updatedHand); + } + + await Clients.Group(game.LobbyCode).SendAsync("GameStateUpdated", updatedGameState); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error completing slip for card {CardId}", cardId); + } + }); } catch (UnauthorizedAccessException ex) { @@ -254,8 +283,59 @@ public class GameHub : Hub } } + public async Task ChallengePlayer(int gameId, int challengingPlayerId, int targetPlayerId) + { + try + { + var userId = GetAuthenticatedUserId(); + await ValidatePlayerAccessAsync(challengingPlayerId, userId); + + var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetPlayerId); + var game = await _gameService.GetGameAsync(gameId); + + using var context = _contextFactory.CreateDbContext(); + var challengingPlayer = await context.Players + .Include(p => p.User) + .FirstOrDefaultAsync(p => p.Id == challengingPlayerId); + + var targetPlayer = await context.Players + .Include(p => p.User) + .FirstOrDefaultAsync(p => p.Id == targetPlayerId); + + var challengeDto = new SlipChallengeDto + { + ChallengeId = challenge.Id, + ChallengingPlayerId = challengingPlayerId, + ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty, + TargetPlayerId = challenge.TargetPlayerId, + TargetPlayerName = targetPlayer?.User.Username ?? string.Empty, + TargetCardId = challenge.TargetCardId, + Status = challenge.Status.ToString(), + CreatedAt = challenge.CreatedAt + }; + + await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto); + + // Sende direkte Notification an den beschuldigten Spieler + if (targetPlayer?.ConnectionId != null) + { + await Clients.Client(targetPlayer.ConnectionId).SendAsync("ChallengeReceived", challengeDto); + } + } + catch (UnauthorizedAccessException ex) + { + await Clients.Caller.SendAsync("Error", new { Message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error challenging player"); + await Clients.Caller.SendAsync("Error", new { Message = "An error occurred while challenging player." }); + } + } + public async Task ChallengeSlip(int gameId, int challengingPlayerId, int targetCardId) { + // Kompatibilitätsmethode try { var userId = GetAuthenticatedUserId(); @@ -269,13 +349,18 @@ public class GameHub : Hub .Include(p => p.User) .FirstOrDefaultAsync(p => p.Id == challengingPlayerId); + var targetPlayer = await context.Players + .Include(p => p.User) + .FirstOrDefaultAsync(p => p.Id == challenge.TargetPlayerId); + var challengeDto = new SlipChallengeDto { ChallengeId = challenge.Id, ChallengingPlayerId = challengingPlayerId, ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty, TargetPlayerId = challenge.TargetPlayerId, - TargetCardId = targetCardId, + TargetPlayerName = targetPlayer?.User.Username ?? string.Empty, + TargetCardId = challenge.TargetCardId, Status = challenge.Status.ToString(), CreatedAt = challenge.CreatedAt }; @@ -283,7 +368,6 @@ public class GameHub : Hub await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto); // Sende direkte Notification an den beschuldigten Spieler - var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId); if (targetPlayer?.ConnectionId != null) { await Clients.Client(targetPlayer.ConnectionId).SendAsync("ChallengeReceived", challengeDto); @@ -300,15 +384,22 @@ public class GameHub : Hub } } - public async Task ResolveChallenge(int challengeId, bool approved) + public async Task ResolveChallenge(int challengeId, bool approved, int? discardCardId = null) { try { var userId = GetAuthenticatedUserId(); await ValidateChallengeTargetAccessAsync(challengeId, userId); - var result = await _gameService.ResolveChallengeAsync(challengeId, approved); - var game = await _gameService.GetGameAsync(result.GameRound.GameId); + var result = await _gameService.ResolveChallengeAsync(challengeId, approved, discardCardId); + int gameId = result.GameRound?.GameId ?? 0; + if (gameId == 0) + { + using var context = _contextFactory.CreateDbContext(); + var round = await context.GameRounds.FindAsync(result.GameRoundId); + gameId = round?.GameId ?? 0; + } + var game = await _gameService.GetGameAsync(gameId); await Clients.Group(game.LobbyCode).SendAsync("ChallengeResolved", new { @@ -319,16 +410,19 @@ public class GameHub : Hub // Aktualisierte Handkarten an betroffene Spieler senden var challengingHand = await _gameService.GetPlayerHandAsync(result.ChallengingPlayerId); var targetHand = await _gameService.GetPlayerHandAsync(result.TargetPlayerId); + var gameState = await _gameService.GetGameStateAsync(gameId); - using var context = _contextFactory.CreateDbContext(); - var challengingPlayer = await context.Players.FindAsync(result.ChallengingPlayerId); - var targetPlayer = await context.Players.FindAsync(result.TargetPlayerId); + using var db = _contextFactory.CreateDbContext(); + var challengingPlayer = await db.Players.FindAsync(result.ChallengingPlayerId); + var targetPlayer = await db.Players.FindAsync(result.TargetPlayerId); if (challengingPlayer?.ConnectionId != null) await Clients.Client(challengingPlayer.ConnectionId).SendAsync("PlayerHandUpdated", challengingHand); if (targetPlayer?.ConnectionId != null) await Clients.Client(targetPlayer.ConnectionId).SendAsync("PlayerHandUpdated", targetHand); + + await Clients.Group(game.LobbyCode).SendAsync("GameStateUpdated", gameState); } catch (UnauthorizedAccessException ex) { @@ -354,6 +448,17 @@ public class GameHub : Hub var gameState = await _gameService.GetGameStateAsync(gameId); await Clients.Caller.SendAsync("GameStateUpdated", gameState); + + // Abgelaufene Slips wurden beim Laden des Zustands abgeschlossen; Hand des Anfragenden mitsenden + using var context = _contextFactory.CreateDbContext(); + var playerId = await context.Players + .Where(p => p.GameId == gameId && p.UserId == userId) + .Select(p => p.Id) + .FirstOrDefaultAsync(); + if (playerId > 0) + { + await Clients.Caller.SendAsync("PlayerHandUpdated", await _gameService.GetPlayerHandAsync(playerId)); + } } catch (UnauthorizedAccessException ex) { @@ -389,43 +494,11 @@ public class GameHub : Hub .Select(g => g.LobbyCode) .FirstAsync(); await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode); - - // Der Client bekommt nach einem Reconnect keinen vollständigen Zustand - // mehr aus der Queue gespielt — er muss RequestGameState aufrufen. } } catch (UnauthorizedAccessException) { // Nicht authentifiziert — ConnectionId wird nicht gespeichert. } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to register connection for authenticated user"); - } - - await base.OnConnectedAsync(); - } - - public override async Task OnDisconnectedAsync(Exception? exception) - { - _logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId); - - // ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen. - try - { - using var context = _contextFactory.CreateDbContext(); - var players = await context.Players - .Where(p => p.ConnectionId == Context.ConnectionId) - .ToListAsync(); - foreach (var player in players) - player.ConnectionId = null; - await context.SaveChangesAsync(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clear connection id on disconnect"); - } - - await base.OnDisconnectedAsync(exception); } } diff --git a/SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.Designer.cs b/SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.Designer.cs new file mode 100644 index 0000000..b4b5f48 --- /dev/null +++ b/SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.Designer.cs @@ -0,0 +1,446 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SlipItIn.Server.Data; + +#nullable disable + +namespace SlipItIn.Server.Migrations +{ + [DbContext(typeof(SlipItInDbContext))] + [Migration("20261004162039_UpdateGameRules")] + partial class UpdateGameRules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SlipItIn.Shared.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HostId") + .HasColumnType("integer"); + + b.Property("LobbyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("RoundDurationSeconds") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("WinnerPlayerId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HostId"); + + b.HasIndex("LobbyCode") + .IsUnique(); + + b.HasIndex("WinnerPlayerId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("RoundNumber") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameRounds"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Phrases"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConnectionId") + .HasColumnType("text"); + + b.Property("FailedSlips") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SuccessfulSlips") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GameId", "UserId") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.PlayerCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameRoundId") + .HasColumnType("integer"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("PhraseId") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("integer"); + + b.Property("SlippedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameRoundId"); + + b.HasIndex("PhraseId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerCards"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.SlipChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChallengingPlayerId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameRoundId") + .HasColumnType("integer"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetCardId") + .HasColumnType("integer"); + + b.Property("TargetPlayerId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChallengingPlayerId"); + + b.HasIndex("GameRoundId"); + + b.HasIndex("TargetCardId"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("SlipChallenges"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Game", b => + { + b.HasOne("SlipItIn.Shared.Models.User", "Host") + .WithMany() + .HasForeignKey("HostId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SlipItIn.Shared.Models.Player", "WinnerPlayer") + .WithMany() + .HasForeignKey("WinnerPlayerId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Host"); + + b.Navigation("WinnerPlayer"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b => + { + b.HasOne("SlipItIn.Shared.Models.Game", "Game") + .WithMany("Rounds") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b => + { + b.HasOne("SlipItIn.Shared.Models.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Player", b => + { + b.HasOne("SlipItIn.Shared.Models.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SlipItIn.Shared.Models.User", "User") + .WithMany("Players") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.PlayerCard", b => + { + b.HasOne("SlipItIn.Shared.Models.GameRound", "GameRound") + .WithMany("ActiveCards") + .HasForeignKey("GameRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SlipItIn.Shared.Models.Phrase", "Phrase") + .WithMany("PlayerCards") + .HasForeignKey("PhraseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SlipItIn.Shared.Models.Player", "Player") + .WithMany("Cards") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GameRound"); + + b.Navigation("Phrase"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.SlipChallenge", b => + { + b.HasOne("SlipItIn.Shared.Models.Player", "ChallengingPlayer") + .WithMany() + .HasForeignKey("ChallengingPlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SlipItIn.Shared.Models.GameRound", "GameRound") + .WithMany("Challenges") + .HasForeignKey("GameRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SlipItIn.Shared.Models.PlayerCard", "TargetCard") + .WithMany() + .HasForeignKey("TargetCardId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SlipItIn.Shared.Models.Player", "TargetPlayer") + .WithMany() + .HasForeignKey("TargetPlayerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ChallengingPlayer"); + + b.Navigation("GameRound"); + + b.Navigation("TargetCard"); + + b.Navigation("TargetPlayer"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Game", b => + { + b.Navigation("Players"); + + b.Navigation("Rounds"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b => + { + b.Navigation("ActiveCards"); + + b.Navigation("Challenges"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Phrase", b => + { + b.Navigation("PlayerCards"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.Player", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("SlipItIn.Shared.Models.User", b => + { + b.Navigation("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.cs b/SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.cs new file mode 100644 index 0000000..98a773b --- /dev/null +++ b/SlipItIn.Server/Migrations/20261004162039_UpdateGameRules.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SlipItIn.Server.Migrations +{ + /// + public partial class UpdateGameRules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TargetCardId", + table: "SlipChallenges", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AddColumn( + name: "SlippedAt", + table: "PlayerCards", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "WinnerPlayerId", + table: "Games", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Games_WinnerPlayerId", + table: "Games", + column: "WinnerPlayerId"); + + migrationBuilder.AddForeignKey( + name: "FK_Games_Players_WinnerPlayerId", + table: "Games", + column: "WinnerPlayerId", + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Games_Players_WinnerPlayerId", + table: "Games"); + + migrationBuilder.DropIndex( + name: "IX_Games_WinnerPlayerId", + table: "Games"); + + migrationBuilder.DropColumn( + name: "SlippedAt", + table: "PlayerCards"); + + migrationBuilder.DropColumn( + name: "WinnerPlayerId", + table: "Games"); + + migrationBuilder.AlterColumn( + name: "TargetCardId", + table: "SlipChallenges", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + } + } +} diff --git a/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs b/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs index e5d7409..33e091f 100644 --- a/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs +++ b/SlipItIn.Server/Migrations/SlipItInDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace SlipItIn.Server.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -55,6 +55,9 @@ namespace SlipItIn.Server.Migrations b.Property("Status") .HasColumnType("integer"); + b.Property("WinnerPlayerId") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("HostId"); @@ -62,6 +65,8 @@ namespace SlipItIn.Server.Migrations b.HasIndex("LobbyCode") .IsUnique(); + b.HasIndex("WinnerPlayerId"); + b.ToTable("Games"); }); @@ -188,6 +193,9 @@ namespace SlipItIn.Server.Migrations b.Property("PlayerId") .HasColumnType("integer"); + b.Property("SlippedAt") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("GameRoundId"); @@ -222,7 +230,7 @@ namespace SlipItIn.Server.Migrations b.Property("Status") .HasColumnType("integer"); - b.Property("TargetCardId") + b.Property("TargetCardId") .HasColumnType("integer"); b.Property("TargetPlayerId") @@ -289,7 +297,14 @@ namespace SlipItIn.Server.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("SlipItIn.Shared.Models.Player", "WinnerPlayer") + .WithMany() + .HasForeignKey("WinnerPlayerId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("Host"); + + b.Navigation("WinnerPlayer"); }); modelBuilder.Entity("SlipItIn.Shared.Models.GameRound", b => @@ -377,8 +392,7 @@ namespace SlipItIn.Server.Migrations b.HasOne("SlipItIn.Shared.Models.PlayerCard", "TargetCard") .WithMany() .HasForeignKey("TargetCardId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.Restrict); b.HasOne("SlipItIn.Shared.Models.Player", "TargetPlayer") .WithMany() diff --git a/SlipItIn.Server/Services/GameService.cs b/SlipItIn.Server/Services/GameService.cs index 50ee4a8..1941bf5 100644 --- a/SlipItIn.Server/Services/GameService.cs +++ b/SlipItIn.Server/Services/GameService.cs @@ -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 CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId) + public async Task 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 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 ResolveChallengeAsync(int challengeId, bool approved) + public async Task 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"; diff --git a/SlipItIn.Server/Services/IGameService.cs b/SlipItIn.Server/Services/IGameService.cs index 988c5ce..fb059a1 100644 --- a/SlipItIn.Server/Services/IGameService.cs +++ b/SlipItIn.Server/Services/IGameService.cs @@ -11,8 +11,9 @@ public interface IGameService Task SetPlayerReadyAsync(int gameId, int playerId); Task> DealCardsAsync(int gameId); Task SubmitSlipAsync(int gameId, int playerId, int cardId); - Task CreateChallengeAsync(int gameId, int challengingPlayerId, int targetCardId); - Task ResolveChallengeAsync(int challengeId, bool approved); + Task CompleteSlipAsync(int gameId, int cardId, bool force = false); + Task CreateChallengeAsync(int gameId, int challengingPlayerId, int targetPlayerId); + Task ResolveChallengeAsync(int challengeId, bool approved, int? discardCardId = null); Task GetGameStateAsync(int gameId); Task GetPlayerHandAsync(int playerId); Task GetGameAsync(int gameId); diff --git a/SlipItIn.Shared/DTOs/GameStateDto.cs b/SlipItIn.Shared/DTOs/GameStateDto.cs index 7ca3424..32f72d7 100644 --- a/SlipItIn.Shared/DTOs/GameStateDto.cs +++ b/SlipItIn.Shared/DTOs/GameStateDto.cs @@ -10,6 +10,8 @@ public class GameStateDto public int HostUserId { get; set; } public int CurrentRound { get; set; } public int RoundTimeRemaining { get; set; } + public int? WinnerPlayerId { get; set; } + public string? WinnerUsername { get; set; } } public class PlayerInfoDto @@ -29,12 +31,55 @@ public class PlayerHandDto public List Cards { get; set; } = []; } -public class PlayerCardDto +public class PlayerCardDto : System.ComponentModel.INotifyPropertyChanged { + private bool _isUsed; + private DateTime? _slippedAt; + private int _slippedHoldSecondsRemaining; + + public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; + public int CardId { get; set; } public int PhraseId { get; set; } public string Text { get; set; } = string.Empty; - public bool IsUsed { get; set; } + + public bool IsUsed + { + get => _isUsed; + set { _isUsed = value; NotifyStateChanged(); } + } + + public DateTime? SlippedAt + { + get => _slippedAt; + set { _slippedAt = value; NotifyStateChanged(); } + } + + public int SlippedHoldSecondsRemaining + { + get => _slippedHoldSecondsRemaining; + set + { + _slippedHoldSecondsRemaining = value; + Raise(nameof(SlippedHoldSecondsRemaining)); + Raise(nameof(SlipButtonText)); + } + } + + public bool IsPendingSlip => SlippedAt.HasValue && !IsUsed; + public bool CanSlip => !IsPendingSlip && !IsUsed; + public string SlipButtonText => IsPendingSlip ? $"Vorgehalten ({SlippedHoldSecondsRemaining}s)" : "Slip"; + + private void NotifyStateChanged() + { + Raise(nameof(IsUsed)); + Raise(nameof(SlippedAt)); + Raise(nameof(IsPendingSlip)); + Raise(nameof(CanSlip)); + Raise(nameof(SlipButtonText)); + } + + private void Raise(string name) => PropertyChanged?.Invoke(this, new(name)); } public class SlipChallengeDto @@ -43,7 +88,8 @@ public class SlipChallengeDto public int ChallengingPlayerId { get; set; } public string ChallengingPlayerName { get; set; } = string.Empty; public int TargetPlayerId { get; set; } - public int TargetCardId { get; set; } + public string TargetPlayerName { get; set; } = string.Empty; + public int? TargetCardId { get; set; } public string Status { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } } diff --git a/SlipItIn.Shared/Models/Game.cs b/SlipItIn.Shared/Models/Game.cs index f215c69..f449207 100644 --- a/SlipItIn.Shared/Models/Game.cs +++ b/SlipItIn.Shared/Models/Game.cs @@ -19,9 +19,11 @@ public class Game public DateTime? EndedAt { get; set; } public int MaxPlayers { get; set; } = 8; public int RoundDurationSeconds { get; set; } = 45; + public int? WinnerPlayerId { get; set; } // Navigation public User Host { get; set; } = null!; + public Player? WinnerPlayer { get; set; } public ICollection Players { get; set; } = []; public ICollection Rounds { get; set; } = []; } diff --git a/SlipItIn.Shared/Models/PlayerCard.cs b/SlipItIn.Shared/Models/PlayerCard.cs index c1846c6..071a9b4 100644 --- a/SlipItIn.Shared/Models/PlayerCard.cs +++ b/SlipItIn.Shared/Models/PlayerCard.cs @@ -7,6 +7,7 @@ public class PlayerCard public int PhraseId { get; set; } public int GameRoundId { get; set; } public bool IsUsed { get; set; } = false; + public DateTime? SlippedAt { get; set; } public DateTime AssignedAt { get; set; } = DateTime.UtcNow; // Navigation diff --git a/SlipItIn.Shared/Models/SlipChallenge.cs b/SlipItIn.Shared/Models/SlipChallenge.cs index 6a92e97..0b6de92 100644 --- a/SlipItIn.Shared/Models/SlipChallenge.cs +++ b/SlipItIn.Shared/Models/SlipChallenge.cs @@ -3,8 +3,8 @@ namespace SlipItIn.Shared.Models; public enum ChallengeStatus { Pending, // Beschuldigung eingegangen, wartet auf Bestätigung/Ablehnung - Approved, // Beschuldigung war berechtigt → Phrase bleibt beim Beschuldiger - Rejected // Beschuldigung falsch → Beschuldiger erhält die Phrase + Approved, // Beschuldigung war berechtigt -> Beschuldigter muss Karte ablegen und 2 neue ziehen + Rejected // Beschuldigung angefochten -> Beschuldigung zurückgenommen } public class SlipChallenge @@ -13,7 +13,7 @@ public class SlipChallenge 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 int? TargetCardId { get; set; } // Optional: Angefochtene Karte falls bekannt/zugeordnet public ChallengeStatus Status { get; set; } = ChallengeStatus.Pending; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? ResolvedAt { get; set; } @@ -22,5 +22,5 @@ public class SlipChallenge 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 + public PlayerCard? TargetCard { get; set; } // Die angefochtene Phrase (optional) } diff --git a/SlipItIn/Services/Interfaces/ISignalRService.cs b/SlipItIn/Services/Interfaces/ISignalRService.cs index 10c5024..ca2db3d 100644 --- a/SlipItIn/Services/Interfaces/ISignalRService.cs +++ b/SlipItIn/Services/Interfaces/ISignalRService.cs @@ -23,8 +23,9 @@ public interface ISignalRService Task PlayerReadyAsync(int gameId, int playerId); Task StartGameAsync(int gameId); Task SubmitSlipAsync(int gameId, int playerId, int cardId); + Task ChallengePlayerAsync(int gameId, int challengingPlayerId, int targetPlayerId); Task ChallengeSlipAsync(int gameId, int challengingPlayerId, int targetCardId); - Task ResolveChallengeAsync(int challengeId, bool approved); + Task ResolveChallengeAsync(int challengeId, bool approved, int? discardCardId = null); Task RequestGameStateAsync(int gameId); Task SendQueuedActionAsync(QueuedGameAction action); diff --git a/SlipItIn/Services/SignalRService.cs b/SlipItIn/Services/SignalRService.cs index d5cf3fd..af64547 100644 --- a/SlipItIn/Services/SignalRService.cs +++ b/SlipItIn/Services/SignalRService.cs @@ -120,11 +120,14 @@ public class SignalRService : ISignalRService, IAsyncDisposable public Task SubmitSlipAsync(int gameId, int playerId, int cardId) => InvokeAsync("SubmitSlip", gameId, playerId, cardId); + public Task ChallengePlayerAsync(int gameId, int challengingPlayerId, int targetPlayerId) + => InvokeAsync("ChallengePlayer", gameId, challengingPlayerId, targetPlayerId); + public Task ChallengeSlipAsync(int gameId, int challengingPlayerId, int targetCardId) => InvokeAsync("ChallengeSlip", gameId, challengingPlayerId, targetCardId); - public Task ResolveChallengeAsync(int challengeId, bool approved) - => InvokeAsync("ResolveChallenge", challengeId, approved); + public Task ResolveChallengeAsync(int challengeId, bool approved, int? discardCardId = null) + => InvokeAsync("ResolveChallenge", challengeId, approved, discardCardId); public Task RequestGameStateAsync(int gameId) => InvokeAsync("RequestGameState", gameId); @@ -249,9 +252,7 @@ public class SignalRService : ISignalRService, IAsyncDisposable return element.ValueKind switch { JsonValueKind.String => element.GetString(), - JsonValueKind.Number when element.TryGetInt32(out var i) => i, - JsonValueKind.Number when element.TryGetInt64(out var l) => l, - JsonValueKind.Number when element.TryGetDouble(out var d) => d, + JsonValueKind.Number => element.TryGetInt32(out var i) ? i : element.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, @@ -262,6 +263,20 @@ public class SignalRService : ISignalRService, IAsyncDisposable public async ValueTask DisposeAsync() { if (_hubConnection is not null) - await _hubConnection.DisposeAsync(); + { + try + { + await _hubConnection.DisposeAsync(); + } + catch + { + // Best effort + } + + _hubConnection = null; + } + + _connectLock.Dispose(); + GC.SuppressFinalize(this); } } diff --git a/SlipItIn/ViewModels/GameBoardViewModel.cs b/SlipItIn/ViewModels/GameBoardViewModel.cs index 2658c91..057c345 100644 --- a/SlipItIn/ViewModels/GameBoardViewModel.cs +++ b/SlipItIn/ViewModels/GameBoardViewModel.cs @@ -26,16 +26,16 @@ public partial class GameBoardViewModel : BaseViewModel, private partial int GameId { get; set; } [ObservableProperty] - private partial int CurrentRound { get; set; } + public partial int CurrentRound { get; set; } [ObservableProperty] - private partial int RoundTimeRemaining { get; set; } + public partial int RoundTimeRemaining { get; set; } [ObservableProperty] - private partial bool IsConnected { get; set; } + public partial bool IsConnected { get; set; } [ObservableProperty] - private partial bool IsSyncing { get; set; } + public partial bool IsSyncing { get; set; } [ObservableProperty] public partial bool IsChallengeVisible { get; set; } @@ -49,6 +49,18 @@ public partial class GameBoardViewModel : BaseViewModel, [ObservableProperty] public partial int PendingSyncActions { get; set; } + [ObservableProperty] + public partial bool IsGameOver { get; set; } + + [ObservableProperty] + public partial string GameOverMessage { get; set; } = string.Empty; + + [ObservableProperty] + public partial PlayerInfoDto? SelectedOtherPlayer { get; set; } + + [ObservableProperty] + public partial PlayerCardDto? SelectedCardToDiscard { get; set; } + public ObservableCollection MyCards { get; } = []; public ObservableCollection OtherPlayers { get; } = []; @@ -71,14 +83,44 @@ public partial class GameBoardViewModel : BaseViewModel, private void OnRoundTimerTick(object? sender, EventArgs e) { - if (RoundTimeRemaining <= 0) - return; - - var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds; - if (elapsed > 0) + if (RoundTimeRemaining > 0) { - RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed); - _lastStateUtc = DateTime.UtcNow; + var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds; + if (elapsed > 0) + { + RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed); + _lastStateUtc = DateTime.UtcNow; + } + } + + // 30-Sekunden Countdown für vorgehaltene Slips aktualisieren + foreach (var card in MyCards) + { + if (card.SlippedAt.HasValue && !card.IsUsed) + { + var elapsedSlip = (int)(DateTime.UtcNow - card.SlippedAt.Value).TotalSeconds; + var remaining = Math.Max(0, 30 - elapsedSlip); + if (card.SlippedHoldSecondsRemaining != remaining) + { + card.SlippedHoldSecondsRemaining = remaining; + // Zeit abgelaufen: Server-Zustand und Hand neu laden, damit die Karte abgelegt erscheint + if (remaining == 0 && _signalR.IsConnected && GameId > 0) + _ = RefreshAfterHoldAsync(); + } + } + } + } + + private async Task RefreshAfterHoldAsync() + { + try + { + await Task.Delay(1000); + await _signalR.RequestGameStateAsync(GameId); + } + catch + { + // Nächster Timer-Tick/Refresh versucht es erneut } } @@ -94,7 +136,7 @@ public partial class GameBoardViewModel : BaseViewModel, [RelayCommand] private async Task SubmitSlipAsync(PlayerCardDto? card) { - if (card is null) + if (card is null || IsGameOver || card.IsPendingSlip) return; await RunSafeAsync(async () => @@ -103,6 +145,10 @@ public partial class GameBoardViewModel : BaseViewModel, if (playerId <= 0 || GameId <= 0) throw new InvalidOperationException("Spielzustand nicht verfügbar."); + // Lokales Feedback sofort setzen: 30s Vorhaltezeit + card.SlippedAt = DateTime.UtcNow; + card.SlippedHoldSecondsRemaining = 30; + if (_signalR.IsConnected) { await _signalR.SubmitSlipAsync(GameId, playerId, card.CardId); @@ -116,9 +162,9 @@ public partial class GameBoardViewModel : BaseViewModel, } [RelayCommand] - private async Task ChallengeCardAsync(PlayerCardDto? card) + private async Task ChallengePlayerAsync(PlayerInfoDto? targetPlayer) { - if (card is null) + if (targetPlayer is null || IsGameOver) return; await RunSafeAsync(async () => @@ -129,16 +175,24 @@ public partial class GameBoardViewModel : BaseViewModel, if (_signalR.IsConnected) { - await _signalR.ChallengeSlipAsync(GameId, playerId, card.CardId); + await _signalR.ChallengePlayerAsync(GameId, playerId, targetPlayer.PlayerId); } else { - await _gameStateService.EnqueueActionAsync("ChallengeSlip", GameId, playerId, card.CardId); + await _gameStateService.EnqueueActionAsync("ChallengePlayer", GameId, playerId, targetPlayer.PlayerId); StatusMessage = "Offline: Aktion wurde zwischengespeichert."; } + + StatusMessage = $"Beschuldigung gegen {targetPlayer.Username} eingereicht!"; }); } + [RelayCommand] + private Task ChallengeSelectedPlayerAsync() + { + return ChallengePlayerAsync(SelectedOtherPlayer); + } + [RelayCommand] private async Task ApproveChallengeAsync() { @@ -146,18 +200,21 @@ public partial class GameBoardViewModel : BaseViewModel, return; var challengeId = PendingChallenge.ChallengeId; + var discardId = SelectedCardToDiscard?.CardId; + PendingChallenge = null; IsChallengeVisible = false; + SelectedCardToDiscard = null; await RunSafeAsync(async () => { if (_signalR.IsConnected) { - await _signalR.ResolveChallengeAsync(challengeId, true); + await _signalR.ResolveChallengeAsync(challengeId, true, discardId); } else { - await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true); + await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true, discardId); StatusMessage = "Offline: Antwort wurde zwischengespeichert."; } }); @@ -172,6 +229,7 @@ public partial class GameBoardViewModel : BaseViewModel, var challengeId = PendingChallenge.ChallengeId; PendingChallenge = null; IsChallengeVisible = false; + SelectedCardToDiscard = null; await RunSafeAsync(async () => { @@ -196,6 +254,19 @@ public partial class GameBoardViewModel : BaseViewModel, RoundTimeRemaining = message.Value.RoundTimeRemaining; _lastStateUtc = DateTime.UtcNow; + if (string.Equals(message.Value.Status, "Completed", StringComparison.OrdinalIgnoreCase)) + { + IsGameOver = true; + GameOverMessage = !string.IsNullOrEmpty(message.Value.WinnerUsername) + ? $"🎉 {message.Value.WinnerUsername} hat alle Phrasen abgelegt und gewonnen! 🎉" + : "🎉 Das Spiel ist beendet! 🎉"; + } + else + { + IsGameOver = false; + GameOverMessage = string.Empty; + } + var me = _authSession.CurrentUser?.Username; var others = message.Value.Players .Where(p => !string.Equals(p.Username, me, StringComparison.OrdinalIgnoreCase)) @@ -204,7 +275,18 @@ public partial class GameBoardViewModel : BaseViewModel, OtherPlayers.Clear(); foreach (var player in others) OtherPlayers.Add(player); + + if (SelectedOtherPlayer != null && !otherPlayersExist(SelectedOtherPlayer.PlayerId)) + { + SelectedOtherPlayer = OtherPlayers.FirstOrDefault(); + } + else if (SelectedOtherPlayer == null && OtherPlayers.Count > 0) + { + SelectedOtherPlayer = OtherPlayers.FirstOrDefault(); + } }); + + bool otherPlayersExist(int id) => message.Value.Players.Any(p => p.PlayerId == id); } public void Receive(PlayerHandChangedMessage message) @@ -213,7 +295,14 @@ public partial class GameBoardViewModel : BaseViewModel, { MyCards.Clear(); foreach (var card in message.Value.Cards.Where(card => !card.IsUsed)) + { MyCards.Add(card); + } + + if (SelectedCardToDiscard == null && MyCards.Count > 0) + { + SelectedCardToDiscard = MyCards.FirstOrDefault(c => c.IsPendingSlip) ?? MyCards.FirstOrDefault(); + } }); } @@ -222,6 +311,7 @@ public partial class GameBoardViewModel : BaseViewModel, MainThread.BeginInvokeOnMainThread(() => { PendingChallenge = message.Value; + SelectedCardToDiscard = MyCards.FirstOrDefault(c => c.IsPendingSlip) ?? MyCards.FirstOrDefault(); IsChallengeVisible = true; }); } @@ -233,12 +323,12 @@ public partial class GameBoardViewModel : BaseViewModel, public void Receive(SyncStateChangedMessage message) { - MainThread.BeginInvokeOnMainThread((Action)(() => + MainThread.BeginInvokeOnMainThread(() => { IsSyncing = message.Value.IsSyncing; SyncStatusText = message.Value.StatusText; PendingSyncActions = message.Value.PendingActions; - })); + }); } public void Receive(ConnectionStateChangedMessage message) diff --git a/SlipItIn/Views/ChallengeNotificationOverlay.xaml b/SlipItIn/Views/ChallengeNotificationOverlay.xaml index f7e5ea2..a715b6a 100644 --- a/SlipItIn/Views/ChallengeNotificationOverlay.xaml +++ b/SlipItIn/Views/ChallengeNotificationOverlay.xaml @@ -6,14 +6,32 @@ x:DataType="vm:GameBoardViewModel" IsVisible="{Binding IsChallengeVisible}"> - - -