From 8c70ad47aa60930cdf6e420a38b38f0a1e320b56 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 01:19:17 -0400 Subject: [PATCH 01/18] Implement blackjack functionality --- .../Models/Games/BlackjackState.cs | 6 +- Project.App/Project.Api/Models/Hand.cs | 6 +- .../Repositories/HandRepository.cs | 23 + .../Repositories/Interface/IHandRepository.cs | 6 + .../Project.Api/Services/BlackjackService.cs | 571 ++++++++++++------ .../Project.Api/Services/DeckApiService.cs | 24 +- .../Services/Interface/IDeckApiService.cs | 18 +- .../Services/Interface/IGameService.cs | 5 + 8 files changed, 472 insertions(+), 187 deletions(-) diff --git a/Project.App/Project.Api/Models/Games/BlackjackState.cs b/Project.App/Project.Api/Models/Games/BlackjackState.cs index 0d8c9ca..a7a838d 100644 --- a/Project.App/Project.Api/Models/Games/BlackjackState.cs +++ b/Project.App/Project.Api/Models/Games/BlackjackState.cs @@ -4,7 +4,8 @@ namespace Project.Api.Models.Games; public record BlackjackState : GameState { - public List DealerHand { get; set; } = []; + public string DealerHand { get; set; } = ""; + public Dictionary Bets { get; set; } = []; } [JsonDerivedType(typeof(BlackjackInitStage), typeDiscriminator: "init")] @@ -32,7 +33,8 @@ public record BlackjackDealingStage : BlackjackStage; // player turn // TODO: figure out how turn order will work -public record BlackjackPlayerActionStage(DateTimeOffset Deadline, int Index) : BlackjackStage; +public record BlackjackPlayerActionStage(DateTimeOffset Deadline, int PlayerIndex, int HandIndex) + : BlackjackStage; // dealer turn and distribute winnings public record BlackjackFinishRoundStage : BlackjackStage; diff --git a/Project.App/Project.Api/Models/Hand.cs b/Project.App/Project.Api/Models/Hand.cs index b2803bd..36f68dc 100644 --- a/Project.App/Project.Api/Models/Hand.cs +++ b/Project.App/Project.Api/Models/Hand.cs @@ -11,9 +11,11 @@ public class Hand public Guid RoomPlayerId { get; set; } - public int Order { get; set; } + public required int Order { get; set; } // order of the player in the game - public int Bet { get; set; } + public int HandNumber { get; set; } = 0; // number of the hand for the player + + public long Bet { get; set; } [ForeignKey("RoomPlayerId")] public virtual RoomPlayer? RoomPlayer { get; set; } diff --git a/Project.App/Project.Api/Repositories/HandRepository.cs b/Project.App/Project.Api/Repositories/HandRepository.cs index 38a6b82..b29cef5 100644 --- a/Project.App/Project.Api/Repositories/HandRepository.cs +++ b/Project.App/Project.Api/Repositories/HandRepository.cs @@ -1,7 +1,9 @@ +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; using Project.Api.Data; using Project.Api.Models; using Project.Api.Repositories.Interface; +using Project.Api.Utilities; namespace Project.Api.Repositories; @@ -71,6 +73,27 @@ public async Task> GetHandsByUserIdAsync(Guid roomId, Guid userId) return (hands == null || hands.Count == 0) ? throw new Exception("No hands found") : hands; } + public async Task GetHandByRoomOrderAsync(Guid roomId, int playerOrder, int handOrder) + { + // Validate roomId + if (roomId == Guid.Empty) + { + throw new ArgumentException("Invalid roomId"); + } + + // Retrieve the hand from the database + Hand? hand = await _context + .Hands.Include(h => h.RoomPlayer) + .FirstOrDefaultAsync(h => + h.RoomPlayer != null + && h.RoomPlayer.RoomId == roomId + && h.Order == playerOrder + && h.HandNumber == handOrder + ); + // return hand or throw exception if not found + return hand ?? throw new NotFoundException("Hand not found"); + } + // Create a new hand public async Task CreateHandAsync(Hand hand) { diff --git a/Project.App/Project.Api/Repositories/Interface/IHandRepository.cs b/Project.App/Project.Api/Repositories/Interface/IHandRepository.cs index 08f6418..2bae6dc 100644 --- a/Project.App/Project.Api/Repositories/Interface/IHandRepository.cs +++ b/Project.App/Project.Api/Repositories/Interface/IHandRepository.cs @@ -20,6 +20,12 @@ public interface IHandRepository // Get all hands by a user in a room Task> GetHandsByUserIdAsync(Guid roomId, Guid userId); + /// + /// Get a hand by room ID, player order, and hand order. + /// + /// NotFoundException no hand is found. + Task GetHandByRoomOrderAsync(Guid roomId, int playerOrder, int handOrder); + // Create a new hand Task CreateHandAsync(Hand hand); diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 33c9651..e6a016a 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -73,7 +73,9 @@ public class BlackjackService( IRoomPlayerRepository roomPlayerRepository, IUserRepository userRepository, IHandRepository handRepository, - IDeckApiService deckApiService + IDeckApiService deckApiService, + IRoomSSEService roomSSEService, + ILogger logger ) : IBlackjackService { private readonly IRoomRepository _roomRepository = roomRepository; @@ -81,6 +83,8 @@ IDeckApiService deckApiService private readonly IUserRepository _userRepository = userRepository; private readonly IHandRepository _handRepository = handRepository; private readonly IDeckApiService _deckApiService = deckApiService; + private readonly IRoomSSEService _roomSSEService = roomSSEService; + private readonly ILogger _logger = logger; private BlackjackConfig _config = new(); public BlackjackConfig Config @@ -109,6 +113,31 @@ public static bool IsActionValid(string action, BlackjackStage stage) => _ => false, }; + public async Task SetupGameAsync(Guid roomId) + { + // create initial game state + BlackjackState initialState = new() + { + CurrentStage = new BlackjackBettingStage( + DateTimeOffset.UtcNow + _config.BettingTimeLimit, + [] + ), + }; + + // create new deck + string deckId = await _deckApiService.CreateDeck(); + + // save to room + Room room = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room {roomId} not found."); + + room.DeckId = deckId; + room.GameState = JsonSerializer.Serialize(initialState); + + await _roomRepository.UpdateAsync(room); + } + public async Task PerformActionAsync( Guid roomId, Guid playerId, @@ -136,227 +165,378 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) switch (actionDTO) { case BetAction betAction: - // check if player has enough chips - if (player.Balance < betAction.Amount) - { - throw new BadRequestException( - $"Player {playerId} does not have enough chips to bet {betAction.Amount}." - ); - } - - BlackjackBettingStage stage = (BlackjackBettingStage)state.CurrentStage; - - // set bet in gamestate - stage.Bets[player.Id] = betAction.Amount; - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); - - // update player status - player.Status = Status.Active; - await _roomPlayerRepository.UpdateAsync(player); + await ProcessBetAsync(state, roomId, player, betAction.Amount); // if not past deadline, do not move to next stage - if (DateTime.UtcNow < stage.Deadline) + if (DateTime.UtcNow < ((BlackjackBettingStage)state.CurrentStage).Deadline) { break; } - // time is up, process all bets - foreach ((Guid better, long bet) in stage.Bets) + // time is up, start the round + await StartRoundAsync(state, roomId); + break; + case HitAction: + bool busted = await DoHitAsync(state, roomId, player); + + if (busted) { - try - { - await _roomPlayerRepository.UpdatePlayerBalanceAsync(better, -bet); - } - catch (NotFoundException) - { - // a bet was recorded for a player who no longer exists? - throw new InternalServerException( - $"Could not find player {better} to process their bet." - ); - } + await NextHandOrFinishRoundAsync(state, roomId); + } + else + { + // stay on the same player's turn, but reset the deadline + state.CurrentStage = new BlackjackPlayerActionStage( + DateTimeOffset.UtcNow + _config.TurnTimeLimit, + ((BlackjackPlayerActionStage)state.CurrentStage).PlayerIndex, + ((BlackjackPlayerActionStage)state.CurrentStage).HandIndex + ); + await _roomRepository.UpdateGameStateAsync( + roomId, + JsonSerializer.Serialize(state) + ); } + break; + + case StandAction: + // do nothing :) - // move to next stage + // next player or next stage + await NextHandOrFinishRoundAsync(state, roomId); + break; + case DoubleAction: + await DoDoubleAsync(state, roomId, player); + // next player or next stage + await NextHandOrFinishRoundAsync(state, roomId); + throw new NotImplementedException(); + case SplitAction splitAction: + await DoSplitAsync(state, roomId, player, splitAction.Amount); + + // stay on the same player's turn, but reset the deadline state.CurrentStage = new BlackjackPlayerActionStage( DateTimeOffset.UtcNow + _config.TurnTimeLimit, - 0 + ((BlackjackPlayerActionStage)state.CurrentStage).PlayerIndex, + ((BlackjackPlayerActionStage)state.CurrentStage).HandIndex ); await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + break; + case SurrenderAction surrenderAction: + await DoSurrenderAsync(state, roomId, player); + + // next player or next stage + await NextHandOrFinishRoundAsync(state, roomId); + break; + case HurryUpAction hurryUpAction: + switch (state.CurrentStage) + { + case BlackjackBettingStage: + // start the round + await StartRoundAsync(state, roomId); + break; + case BlackjackPlayerActionStage: + // act as if the player stood + await NextHandOrFinishRoundAsync(state, roomId); + break; + default: + throw new BadRequestException("Nothing to hurry in the current stage."); + } + break; + default: + throw new BadRequestException("Unrecognized action type."); + } + } - // TODO: dealing stage + /// + /// Process a player's bet during the betting stage. + /// + /// Thrown if the player does not have enough chips to bet. + private async Task ProcessBetAsync( + BlackjackState state, + Guid roomId, + RoomPlayer player, + long bet + ) + { + // check if player has enough chips + if (player.Balance < bet) + { + throw new BadRequestException( + $"Player {player.UserId} does not have enough chips to bet {bet}." + ); + } - // TODO: move to player action stage + BlackjackBettingStage stage = (BlackjackBettingStage)state.CurrentStage; - break; - case HitAction hitAction: - // Fetch player's hands - var hands = - await _handRepository.GetHandsByRoomIdAsync(player.Id) - ?? throw new BadRequestException("No hand found for this player."); - - var hand = - hands.FirstOrDefault() - ?? throw new BadRequestException("No hand found for this player."); - - // Retrieve deck ID from room configuration or state - var room = - await _roomRepository.GetByIdAsync(roomId) - ?? throw new BadRequestException("Room not found."); - - // Draw one card and add it to player's hand - var drawnCards = await _deckApiService.DrawCards( - room.DeckId?.ToString() - ?? throw new InternalServerException($"Deck for room {roomId} not found."), - hand.Id.ToString(), - 1 - ); + // set bet in gamestate + stage.Bets[player.Id] = bet; + await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); - // --- Calculate total value and check for bust --- - int totalValue = 0; - int aceCount = 0; + // update player status + player.Status = Status.Active; + await _roomPlayerRepository.UpdateAsync(player); + } - foreach (var card in drawnCards) - { - switch (card.Value.ToUpper()) + /// + /// Start the round after betting is complete. + /// Deduct bets from player balances and move to dealing stage. + /// + private async Task StartRoundAsync(BlackjackState state, Guid roomId) + { + // move to dealing stage + state.CurrentStage = new BlackjackDealingStage(); + await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + + // get bets + Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; + + // deduct bets from player balances and initialize hands + int order = 0; + foreach ((Guid better, long bet) in bets) + { + try + { + await _roomPlayerRepository.UpdatePlayerBalanceAsync(better, -bet); + + await _handRepository.CreateHandAsync( + new Hand { - case "ACE": - aceCount++; - totalValue += 11; - break; - case "KING": - case "QUEEN": - case "JACK": - totalValue += 10; - break; - default: - //Number cards (2–10) → handled by: - if (int.TryParse(card.Value, out int val)) - totalValue += val; - break; + RoomPlayerId = better, + Order = order++, + Bet = bet, } - } + ); + } + catch (NotFoundException) + { + // a bet was recorded for a player who no longer exists? + // should not happen, but just in case + throw new InternalServerException( + $"Could not find player {better} to process their bet." + ); + } + } - while (totalValue > 21 && aceCount > 0) - { - totalValue -= 10; - aceCount--; - } + // get deck ID + Room room = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room {roomId} not found."); + if (room.DeckId == null) + { + _logger.LogError( + "Warning: Room {RoomId} has no deck ID. Attempting to create a new one...", + roomId + ); - if (totalValue > 21) - { - await _roomPlayerRepository.UpdateAsync(player); - await NextHandOrFinishRoundAsync(state, roomId); - } - await NextHandOrFinishRoundAsync(state, roomId); - break; + // create new deck + room.DeckId = await _deckApiService.CreateDeck(); + await _roomRepository.UpdateAsync(room); - case StandAction standAction: - // next player or next stage - await NextHandOrFinishRoundAsync(state, roomId); - break; - case DoubleAction doubleAction: - // can only be done on the player's first turn! + _logger.LogInformation( + "Successfully created new deck {DeckId} for room {RoomId}!", + room.DeckId, + roomId + ); + } - // check if player has enough chips to double their bet + // deal initial cards (2 to each player, 2 to dealer, one at a time) + string deckId = room.DeckId.ToString(); + List hands = await _handRepository.GetHandsByRoomIdAsync(roomId); - // double player's bet (deduct from balance and update gamestate) + if (hands.Count == 0) + { + throw new InternalServerException($"No hands found for room {roomId}."); + } - // draw one card + // one card at a time, deal 2 rounds to players and dealer + for (int i = 0; i < 2; i++) + { + // deal one card to each hand in order + foreach (Hand hand in hands.OrderBy(h => h.Order)) + { + await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + } + + // deal to dealer + await _deckApiService.DrawCards(deckId, "dealer", 1); + } - // next player or next stage - await NextHandOrFinishRoundAsync(state, roomId); - throw new NotImplementedException(); - case SplitAction splitAction: - // can only be done on the player's first turn! + // move to player action stage + state.CurrentStage = new BlackjackPlayerActionStage( + DateTimeOffset.UtcNow + _config.TurnTimeLimit, + 0, + 0 + ); + await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + } - // check if player has enough chips to do the new bet + private async Task<(BlackjackPlayerActionStage, Hand, string)> InitializePlayerActionAsync( + BlackjackState state, + Guid roomId, + RoomPlayer player + ) + { + BlackjackPlayerActionStage stage = + state.CurrentStage as BlackjackPlayerActionStage + ?? throw new InternalServerException("Current stage is not player action stage."); + + Hand hand = await _handRepository.GetHandByRoomOrderAsync( + roomId, + stage.PlayerIndex, + stage.HandIndex + ); - // deal new hand (2 cards) + // make sure player corresponds to the current turn's player + if (hand.RoomPlayerId != player.Id) + { + throw new BadRequestException("It is not your turn."); + } - // next turn should be player's first hand - // can only be done on the player's first turn! + Room room = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room {roomId} not found."); - // check if player has enough chips to do the new bet + return ( + stage, + hand, + room.DeckId ?? throw new InternalServerException($"Room {roomId} has no deck ID.") + ); + } - // deal new hand (2 cards) + /// + /// Process a player's "hit" action during their turn. + /// + /// true if player busted + private async Task DoHitAsync(BlackjackState state, Guid roomId, RoomPlayer player) + { + (BlackjackPlayerActionStage stage, Hand hand, string deckId) = + await InitializePlayerActionAsync(state, roomId, player); - // next turn should be player's first hand - throw new NotImplementedException(); - case SurrenderAction surrenderAction: - // not allowed after splitting! - // maybe check if player only has one hand? + // draw one card and add it to player's hand + List handCards = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); - // refund half of player's bet (deduct from balance and update gamestate) + // check if player busted + int totalValue = CalculateHandValue(handCards); - // next player or next stage - await NextHandOrFinishRoundAsync(state, roomId); - // not allowed after splitting! - // maybe check if player only has one hand? + return totalValue > 21; + } - // refund half of player's bet (deduct from balance and update gamestate) + /// + /// Process a player's "double" action during their turn, doubling their bet and drawing one card. + /// + /// Should end the player's turn. + private async Task DoDoubleAsync(BlackjackState state, Guid roomId, RoomPlayer player) + { + (BlackjackPlayerActionStage stage, Hand hand, string deckId) = + await InitializePlayerActionAsync(state, roomId, player); - // next player or next stage - await NextHandOrFinishRoundAsync(state, roomId); - throw new NotImplementedException(); - case HurryUpAction hurryUpAction: - if (state.CurrentStage is BlackjackBettingStage bettingStage) - { - // Check if deadline has passed - if (DateTime.UtcNow < bettingStage.Deadline) - { - throw new BadRequestException( - "Cannot hurry up - betting deadline has not passed yet." - ); - } + // can only be done on the player's first turn! + // check if player only has two cards in hand and is on their first hand + List handCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); - // Process all bets - foreach ((Guid better, long bet) in bettingStage.Bets) - { - try - { - await _roomPlayerRepository.UpdatePlayerBalanceAsync(better, -bet); - } - catch (NotFoundException) - { - // a bet was recorded for a player who no longer exists? - throw new InternalServerException( - $"Could not find player {better} to process their bet." - ); - } - } + if (stage.HandIndex != 0 || handCards.Count > 2) + { + throw new BadRequestException("Double can only be done on the player's first turn."); + } - // Move to next stage - state.CurrentStage = new BlackjackPlayerActionStage( - DateTimeOffset.UtcNow + _config.TurnTimeLimit, - 0 - ); - await _roomRepository.UpdateGameStateAsync( - roomId, - JsonSerializer.Serialize(state) - ); + // check if player has enough chips to double their bet + if (player.Balance < hand.Bet) + { + throw new BadRequestException( + $"Player {player.UserId} does not have enough chips to double their bet." + ); + } - // TODO: dealing stage + // double player's bet (deduct from balance and update gamestate) + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, -hand.Bet); + hand.Bet *= 2; + await _handRepository.UpdateHandAsync(hand.Id, hand); - // TODO: move to player action stage - } - else if (state.CurrentStage is BlackjackPlayerActionStage playerActionStage) - { - // Check if deadline has passed - if (DateTime.UtcNow < playerActionStage.Deadline) - { - throw new BadRequestException( - "Cannot hurry up - player action deadline has not passed yet." - ); - } + // draw one card and add it to player's hand + await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + } - // Move to next player or finish round - await NextHandOrFinishRoundAsync(state, roomId); - } - break; - default: - throw new NotImplementedException(); + /// + /// Process a player's "split" action during their turn, splitting their hand into two hands. + /// + /// Next turn should be the player's first hand. + private async Task DoSplitAsync( + BlackjackState state, + Guid roomId, + RoomPlayer player, + long amount + ) + { + (BlackjackPlayerActionStage stage, Hand hand, string deckId) = + await InitializePlayerActionAsync(state, roomId, player); + + // check if player has enough chips to do the new bet + if (player.Balance < amount) + { + throw new BadRequestException( + $"Player {player.UserId} does not have enough chips to split their bet with {amount}." + ); + } + + // can only be done on the player's first turn! + // check if player only has two cards in hand and is on their first hand + List handCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); + + if (stage.HandIndex != 0 || handCards.Count != 2) + { + throw new BadRequestException("Double can only be done on the player's first turn."); + } + + // check if both cards are the same value + if (handCards[0].Value != handCards[1].Value) + { + throw new BadRequestException("Can only split if both cards have the same value."); + } + + // create new hand with second card + await _handRepository.CreateHandAsync( + new() + { + RoomPlayerId = hand.RoomPlayerId, + Order = hand.Order, + HandNumber = hand.HandNumber + 1, + Bet = amount, + } + ); + Hand newHand = await _handRepository.GetHandByRoomOrderAsync( + roomId, + hand.Order, + hand.HandNumber + 1 + ); + + // move second card to new hand + CardDTO cardToMove = handCards[1]; + await _deckApiService.RemoveFromHand(deckId, $"hand-{hand.Id}", cardToMove.Code); + await _deckApiService.AddToHand(deckId, $"hand-{newHand.Id}", cardToMove.Code); + + // draw one card for each hand + await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + await _deckApiService.DrawCards(deckId, $"hand-{newHand.Id}", 1); + + // deduct bet from player's balance + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, -amount); + } + + /// + /// Process a player's "surrender" action during their turn, forfeiting half their bet and ending their turn. + /// + private async Task DoSurrenderAsync(BlackjackState state, Guid roomId, RoomPlayer player) + { + // not allowed after splitting! + // check if player only has one hand + List hands = await _handRepository.GetHandsByUserIdAsync(roomId, player.Id); + if (hands.Count > 1) + { + throw new BadRequestException("Surrender is not allowed after splitting."); } + + // refund half of player's bet (add to balance and update gamestate) + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, hands[0].Bet / 2); } /// @@ -452,4 +632,39 @@ private async Task FinishRoundAsync(BlackjackState state, Guid roomId) await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); } + + static int CalculateHandValue(List hand, int target = 21) + { + int totalValue = 0; + int aceCount = 0; + + foreach (CardDTO card in hand) + { + switch (card.Value.ToUpper()) + { + case "ACE": + aceCount++; + totalValue += 11; + break; + case "KING": + case "QUEEN": + case "JACK": + totalValue += 10; + break; + default: + //Number cards (2–10) → handled by: + if (int.TryParse(card.Value, out int val)) + totalValue += val; + break; + } + } + + while (totalValue > target && aceCount > 0) + { + totalValue -= 10; + aceCount--; + } + + return totalValue; + } } diff --git a/Project.App/Project.Api/Services/DeckApiService.cs b/Project.App/Project.Api/Services/DeckApiService.cs index ef753dd..b25f061 100644 --- a/Project.App/Project.Api/Services/DeckApiService.cs +++ b/Project.App/Project.Api/Services/DeckApiService.cs @@ -87,7 +87,7 @@ public async Task> DrawCards(string deckId, long handId, int count /// Player draws specified number of cards, count, from specified deck. /// Draws one card by default. /// - /// true if successful + /// the resulting contents of the player’s hand public async Task> DrawCards(string deckId, string handName, int count = 1) { // enforce non-negative count @@ -139,10 +139,10 @@ public async Task ReturnAllCardsToDeck(string deckId) } /// - /// Calls Api to add card to specified hand. If hand does not exist, will create a hand with give handName. + /// Calls Api to add card to specified hand. If hand does not exist, will create a hand with the given handName. /// /// true if successful - private async Task AddToHand(string deckId, string handName, string cardCodes) + public async Task AddToHand(string deckId, string handName, string cardCodes) { string addToPileUrl = $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/add/?cards={cardCodes}"; @@ -154,11 +154,27 @@ private async Task AddToHand(string deckId, string handName, string cardCo return true; } + /// + /// Calls Api to remove cards from specified hand. + /// + /// true if successful + public async Task RemoveFromHand(string deckId, string handName, string cardCodes) + { + string removeFromPileUrl = + $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/draw/?cards={cardCodes}"; + var removeResponse = await _httpClient.GetAsync(removeFromPileUrl); + if (!removeResponse.IsSuccessStatusCode) + { + throw new HttpRequestException("Failed to remove cards from hand."); + } + return true; + } + /// /// Calls Api to list cards in specified pile from specified deck. /// /// A list of card DTOs - private async Task> ListHand(string deckId, string handName) + public async Task> ListHand(string deckId, string handName) { string listPileUrl = $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/list/"; var listResponse = await _httpClient.GetAsync(listPileUrl); diff --git a/Project.App/Project.Api/Services/Interface/IDeckApiService.cs b/Project.App/Project.Api/Services/Interface/IDeckApiService.cs index ed9d8b3..4ae3248 100644 --- a/Project.App/Project.Api/Services/Interface/IDeckApiService.cs +++ b/Project.App/Project.Api/Services/Interface/IDeckApiService.cs @@ -12,7 +12,7 @@ public interface IDeckApiService numOfDecks = 6 enableJokers = false */ - Task CreateDeck(int numOfDecks, bool enableJokers); + Task CreateDeck(int numOfDecks = 6, bool enableJokers = false); /* Create an empty hand (pile) identified by handName within the specified deck. @@ -45,4 +45,20 @@ Return all cards from all piles back to the main deck and shuffles deck. Returns true if successful. */ Task ReturnAllCardsToDeck(string deckId); + + /// + /// Calls Api to add card to specified hand. If hand does not exist, will create a hand with the given handName. + /// + Task AddToHand(string deckId, string handName, string cardCodes); + + /// + /// Calls Api to remove cards from specified hand. + /// + /// true if successful + Task RemoveFromHand(string deckId, string handName, string cardCodes); + + /// + /// Lists all cards in specified hand (pile) from specified deck. + /// + Task> ListHand(string deckId, string handName); } diff --git a/Project.App/Project.Api/Services/Interface/IGameService.cs b/Project.App/Project.Api/Services/Interface/IGameService.cs index 03fe9f1..0abbb31 100644 --- a/Project.App/Project.Api/Services/Interface/IGameService.cs +++ b/Project.App/Project.Api/Services/Interface/IGameService.cs @@ -16,6 +16,11 @@ public interface IGameService Task GetGameStateAsync(Guid gameId); + /// + /// Sets up the game with initial state. + /// + Task SetupGameAsync(Guid gameId); + /// /// Performs a user action on the game, if valid, then updates the game state. /// Each game implementation provides their own main loop logic implementation, instead of being restricted to a From 1135c581d43bb2a08fcc341e6f8782a0d83a3c9a Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 02:52:28 -0400 Subject: [PATCH 02/18] Implement round resetting --- Project.App/Project.Api/DTOs/HandDTO.cs | 3 +- .../Models/Games/BlackjackState.cs | 25 ++ Project.App/Project.Api/Models/Room.cs | 2 + Project.App/Project.Api/Models/RoomPlayer.cs | 2 +- .../Project.Api/Services/BlackjackService.cs | 241 ++++++++++++------ .../Project.Api/Services/DeckApiService.cs | 4 +- .../Services/Interface/IDeckApiService.cs | 2 +- .../Project.Api/Services/RoomService.cs | 4 +- .../Utilities/Extensions/RoomExtensions.cs | 36 +++ .../Services/BlackjackServiceTest.cs | 57 ++++- 10 files changed, 289 insertions(+), 87 deletions(-) create mode 100644 Project.App/Project.Api/Utilities/Extensions/RoomExtensions.cs diff --git a/Project.App/Project.Api/DTOs/HandDTO.cs b/Project.App/Project.Api/DTOs/HandDTO.cs index da5a09d..9832aa3 100644 --- a/Project.App/Project.Api/DTOs/HandDTO.cs +++ b/Project.App/Project.Api/DTOs/HandDTO.cs @@ -7,7 +7,8 @@ public class HandDTO public required Guid RoomPlayerId { get; set; } public required int Order { get; set; } + public int HandNumber { get; set; } = 0; - public required int Bet { get; set; } + public required long Bet { get; set; } } } diff --git a/Project.App/Project.Api/Models/Games/BlackjackState.cs b/Project.App/Project.Api/Models/Games/BlackjackState.cs index a7a838d..02b9df7 100644 --- a/Project.App/Project.Api/Models/Games/BlackjackState.cs +++ b/Project.App/Project.Api/Models/Games/BlackjackState.cs @@ -41,3 +41,28 @@ public record BlackjackFinishRoundStage : BlackjackStage; // teardown, close room public record BlackjackTeardownStage : BlackjackStage; + +public static class BlackjackStateExtensions +{ + /// + /// Resets the deadline of a betting stage to the current time plus the given duration. + /// + public static BlackjackBettingStage ResetDeadline( + this BlackjackBettingStage stage, + TimeSpan duration + ) + { + return stage with { Deadline = DateTimeOffset.UtcNow + duration }; + } + + /// + /// Resets the deadline of a player action stage to the current time plus the given duration. + /// + public static BlackjackPlayerActionStage ResetDeadline( + this BlackjackPlayerActionStage stage, + TimeSpan duration + ) + { + return stage with { Deadline = DateTimeOffset.UtcNow + duration }; + } +} diff --git a/Project.App/Project.Api/Models/Room.cs b/Project.App/Project.Api/Models/Room.cs index 14d4996..035a65e 100644 --- a/Project.App/Project.Api/Models/Room.cs +++ b/Project.App/Project.Api/Models/Room.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Project.Api.Repositories.Interface; +using Project.Api.Services.Interface; namespace Project.Api.Models; diff --git a/Project.App/Project.Api/Models/RoomPlayer.cs b/Project.App/Project.Api/Models/RoomPlayer.cs index 8d6690a..3b3a9be 100644 --- a/Project.App/Project.Api/Models/RoomPlayer.cs +++ b/Project.App/Project.Api/Models/RoomPlayer.cs @@ -26,7 +26,7 @@ public class RoomPlayer public Role Role { get; set; } - public Status Status { get; set; } + public Status Status { get; set; } = Status.Active; [Required] public long Balance { get; set; } diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index e6a016a..d109630 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.AspNetCore.Http.HttpResults; using Project.Api.DTOs; using Project.Api.Models; using Project.Api.Models.Games; @@ -6,6 +7,7 @@ using Project.Api.Services.Interface; using Project.Api.Utilities; using Project.Api.Utilities.Enums; +using Project.Api.Utilities.Extensions; namespace Project.Api.Services; @@ -186,10 +188,8 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) else { // stay on the same player's turn, but reset the deadline - state.CurrentStage = new BlackjackPlayerActionStage( - DateTimeOffset.UtcNow + _config.TurnTimeLimit, - ((BlackjackPlayerActionStage)state.CurrentStage).PlayerIndex, - ((BlackjackPlayerActionStage)state.CurrentStage).HandIndex + ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( + _config.TurnTimeLimit ); await _roomRepository.UpdateGameStateAsync( roomId, @@ -214,10 +214,8 @@ await _roomRepository.UpdateGameStateAsync( await DoSplitAsync(state, roomId, player, splitAction.Amount); // stay on the same player's turn, but reset the deadline - state.CurrentStage = new BlackjackPlayerActionStage( - DateTimeOffset.UtcNow + _config.TurnTimeLimit, - ((BlackjackPlayerActionStage)state.CurrentStage).PlayerIndex, - ((BlackjackPlayerActionStage)state.CurrentStage).HandIndex + ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( + _config.TurnTimeLimit ); await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); break; @@ -231,10 +229,24 @@ await _roomRepository.UpdateGameStateAsync( switch (state.CurrentStage) { case BlackjackBettingStage: + // check if all active players have bet + List activePlayersList = + [ + .. await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId), + ]; + if (activePlayersList.Count != state.Bets.Count) + { + throw new BadRequestException("Not all active players have bet yet."); + } + // start the round await StartRoundAsync(state, roomId); break; case BlackjackPlayerActionStage: + // mark player as inactive + player.Status = Status.Inactive; + await _roomPlayerRepository.UpdateAsync(player); + // act as if the player stood await NextHandOrFinishRoundAsync(state, roomId); break; @@ -283,13 +295,13 @@ long bet /// private async Task StartRoundAsync(BlackjackState state, Guid roomId) { + // get bets + Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; + // move to dealing stage state.CurrentStage = new BlackjackDealingStage(); await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); - // get bets - Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; - // deduct bets from player balances and initialize hands int order = 0; foreach ((Guid better, long bet) in bets) @@ -321,26 +333,9 @@ await _handRepository.CreateHandAsync( Room room = await _roomRepository.GetByIdAsync(roomId) ?? throw new NotFoundException($"Room {roomId} not found."); - if (room.DeckId == null) - { - _logger.LogError( - "Warning: Room {RoomId} has no deck ID. Attempting to create a new one...", - roomId - ); - - // create new deck - room.DeckId = await _deckApiService.CreateDeck(); - await _roomRepository.UpdateAsync(room); - - _logger.LogInformation( - "Successfully created new deck {DeckId} for room {RoomId}!", - room.DeckId, - roomId - ); - } + string deckId = await room.GetOrCreateDeckId(_deckApiService, _roomRepository, _logger); // deal initial cards (2 to each player, 2 to dealer, one at a time) - string deckId = room.DeckId.ToString(); List hands = await _handRepository.GetHandsByRoomIdAsync(roomId); if (hands.Count == 0) @@ -370,6 +365,12 @@ await _roomRepository.GetByIdAsync(roomId) await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); } + /// + /// Initializes a player action. + /// + /// + /// A tuple containing the current player action stage, the player's hand, and the deck ID. + /// private async Task<(BlackjackPlayerActionStage, Hand, string)> InitializePlayerActionAsync( BlackjackState state, Guid roomId, @@ -551,28 +552,56 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) ); } - // Get all active players in the room - IEnumerable activePlayers = - await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId); - List activePlayersList = activePlayers.ToList(); + BlackjackPlayerActionStage stage = (BlackjackPlayerActionStage)state.CurrentStage; + + // check if current player's next hand exists + try + { + int nextHandIndex = stage.HandIndex + 1; + Hand nextHand = await _handRepository.GetHandByRoomOrderAsync( + roomId, + stage.PlayerIndex, + stage.HandIndex + 1 + ); - // Move to next player - int nextIndex = playerActionStage.Index + 1; + // if so, move to next hand + state.CurrentStage = new BlackjackPlayerActionStage( + DateTimeOffset.UtcNow + _config.TurnTimeLimit, + stage.PlayerIndex, + stage.HandIndex + 1 + ); + } + catch (NotFoundException) + { + // player has no more hands + } - // If there are more players, continue with player actions - if (nextIndex < activePlayersList.Count) + // if not, check if next player's first hand exists + try { + int nextPlayerIndex = stage.PlayerIndex + 1; + Hand nextPlayerFirstHand = await _handRepository.GetHandByRoomOrderAsync( + roomId, + nextPlayerIndex, + 0 + ); + + // if so, move to next player state.CurrentStage = new BlackjackPlayerActionStage( DateTimeOffset.UtcNow + _config.TurnTimeLimit, - nextIndex + nextPlayerIndex, + 0 ); await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + return; } - else + catch (NotFoundException) { - // All players have finished, move to dealer turn and finish round - await FinishRoundAsync(state, roomId); + // no more players } + + // all players have finished, move to dealer turn and finish round + await FinishRoundAsync(state, roomId); } // After the players have finished playing, the dealer's hand is resolved by drawing cards until @@ -584,52 +613,82 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) // A blackjack beats any hand that is not a blackjack, even one with a value of 21. private async Task FinishRoundAsync(BlackjackState state, Guid roomId) { - // Transition to finish round stage + // transition to finish round stage state.CurrentStage = new BlackjackFinishRoundStage(); await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); - // TODO: Reveal dealer's hidden card (requires deck API integration) - - // TODO: Dealer plays - hit until 17 or higher (requires deck API integration) - // For now, calculate dealer value from existing hand - // In a complete implementation: - // while (CalculateHandValue(state.DealerHand) < 17) - // { - // var card = await DrawCardFromDeck(roomId); - // state.DealerHand.Add(card); - // } - - // TODO: Calculate winnings for each player hand - // Get all active players - IEnumerable activePlayers = - await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId); - - // For each player: - // 1. Get their hands from the database - // 2. Calculate hand value - // 3. Compare with dealer hand - // 4. Determine winnings (blackjack pays 3:2, regular win pays 1:1, push returns bet) - // 5. Update player balance - - foreach (RoomPlayer player in activePlayers) + Room room = + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room {roomId} not found."); + + List dealerHand = await _deckApiService.ListHand( + room.DeckId ?? throw new InternalServerException($"Room {roomId} has no deck ID."), + "dealer" + ); + + // hit until dealer has at least 17 + int dealerValue = CalculateHandValue(dealerHand); + while (dealerValue < 17) { - // TODO: Implement hand evaluation and payout logic - // This requires: - // - Getting player hands from database - // - Parsing card data from JSON - // - Comparing with dealer hand - // - Updating balances via repository + dealerHand = await _deckApiService.DrawCards( + room.DeckId ?? throw new InternalServerException($"Room {roomId} has no deck ID."), + "dealer", + 1 + ); + dealerValue = CalculateHandValue(dealerHand); + } + + // calculate winnings for each player hand + List hands = await _handRepository.GetHandsByRoomIdAsync(roomId); + foreach (Hand hand in hands) + { + List playerHand = await _deckApiService.ListHand( + room.DeckId ?? throw new InternalServerException($"Room {roomId} has no deck ID."), + $"hand-{hand.Id}" + ); + + switch (CompareHands(dealerHand, playerHand)) + { + case > 0: + // player wins + long winnings = hand.Bet * 2; + await _roomPlayerRepository.UpdatePlayerBalanceAsync( + hand.RoomPlayerId, + winnings + ); + break; + case 0: + // push + await _roomPlayerRepository.UpdatePlayerBalanceAsync( + hand.RoomPlayerId, + hand.Bet + ); + break; + case < 0: + // dealer wins, do nothing + break; + } + } + + // reset player hands + foreach (Hand hand in hands) + { + await _handRepository.DeleteHandAsync(hand.Id); } - // Initialize next betting stage + // return all cards to deck and shuffle + string deckId = await room.GetOrCreateDeckId(_deckApiService, _roomRepository, _logger); + bool success = await _deckApiService.ReturnAllCardsToDeck(deckId); + if (!success) + { + _logger.LogError("Failed to return cards to deck for room {RoomId}, skipping.", roomId); + } + + // initialize next betting stage state.CurrentStage = new BlackjackBettingStage( DateTimeOffset.UtcNow + _config.BettingTimeLimit, [] ); - - // Reset dealer hand for next round - state.DealerHand = []; - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); } @@ -667,4 +726,32 @@ static int CalculateHandValue(List hand, int target = 21) return totalValue; } + + /// + /// Compares dealer and player hands. + /// + /// + /// + /// Positive if player wins, negative if dealer wins, 0 if push + static int CompareHands(List dealerHand, List playerHand) + { + int dealerValue = CalculateHandValue(dealerHand); + int playerValue = CalculateHandValue(playerHand); + + // if player busts, dealer always wins + if (playerValue > 21) + return -1; + + // otherwise, if dealer busts, player wins + if (dealerValue > 21) + return 1; + + // blackjack beats a 21 + if (playerValue == 21 && dealerValue == 21) + { + return dealerHand.Count - playerHand.Count; + } + + return playerValue - dealerValue; + } } diff --git a/Project.App/Project.Api/Services/DeckApiService.cs b/Project.App/Project.Api/Services/DeckApiService.cs index b25f061..a04c9d2 100644 --- a/Project.App/Project.Api/Services/DeckApiService.cs +++ b/Project.App/Project.Api/Services/DeckApiService.cs @@ -126,9 +126,9 @@ public async Task> DrawCards(string deckId, string handName, int c /// Return all cards from all piles back to the main deck. /// /// true if successful - public async Task ReturnAllCardsToDeck(string deckId) + public async Task ReturnAllCardsToDeck(string deckId, bool shuffle = true) { - string url = $"{BASE_API_URL}/deck/{deckId}/return/"; + string url = $"{BASE_API_URL}/deck/{deckId}/{(shuffle ? "shuffle" : "return")}/"; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) diff --git a/Project.App/Project.Api/Services/Interface/IDeckApiService.cs b/Project.App/Project.Api/Services/Interface/IDeckApiService.cs index 4ae3248..ecfbc6a 100644 --- a/Project.App/Project.Api/Services/Interface/IDeckApiService.cs +++ b/Project.App/Project.Api/Services/Interface/IDeckApiService.cs @@ -44,7 +44,7 @@ Draws one card by default. Return all cards from all piles back to the main deck and shuffles deck. Returns true if successful. */ - Task ReturnAllCardsToDeck(string deckId); + Task ReturnAllCardsToDeck(string deckId, bool shuffle = true); /// /// Calls Api to add card to specified hand. If hand does not exist, will create a hand with the given handName. diff --git a/Project.App/Project.Api/Services/RoomService.cs b/Project.App/Project.Api/Services/RoomService.cs index 64e5ce7..b08e45b 100644 --- a/Project.App/Project.Api/Services/RoomService.cs +++ b/Project.App/Project.Api/Services/RoomService.cs @@ -249,9 +249,9 @@ await _roomPlayerRepository.UpdatePlayersInRoomAsync( { CurrentStage = new BlackjackBettingStage( DateTimeOffset.UtcNow + config.BettingTimeLimit, - new Dictionary() + [] ), - DealerHand = [], + DealerHand = "", }; initialGameState = JsonSerializer.Serialize(blackjackState); diff --git a/Project.App/Project.Api/Utilities/Extensions/RoomExtensions.cs b/Project.App/Project.Api/Utilities/Extensions/RoomExtensions.cs new file mode 100644 index 0000000..bf1535c --- /dev/null +++ b/Project.App/Project.Api/Utilities/Extensions/RoomExtensions.cs @@ -0,0 +1,36 @@ +using Project.Api.Models; +using Project.Api.Repositories.Interface; +using Project.Api.Services.Interface; + +namespace Project.Api.Utilities.Extensions; + +public static class RoomExtensions +{ + public static async Task GetOrCreateDeckId( + this Room room, + IDeckApiService deckApiService, + IRoomRepository roomRepository, + ILogger? logger = null + ) + { + if (room.DeckId == null) + { + logger?.LogError( + "Warning: Room {RoomId} has no deck ID. Attempting to create a new one...", + room.Id + ); + + // create new deck + room.DeckId = await deckApiService.CreateDeck(); + await roomRepository.UpdateAsync(room); + + logger?.LogInformation( + "Successfully created new deck {DeckId} for room {RoomId}!", + room.DeckId, + room.Id + ); + } + + return room.DeckId; + } +} diff --git a/Project.App/Project.Test/Services/BlackjackServiceTest.cs b/Project.App/Project.Test/Services/BlackjackServiceTest.cs index 2ede3ee..21ca5ec 100644 --- a/Project.App/Project.Test/Services/BlackjackServiceTest.cs +++ b/Project.App/Project.Test/Services/BlackjackServiceTest.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Moq; using Project.Api.DTOs; using Project.Api.Models; @@ -7,6 +8,7 @@ using Project.Api.Services; using Project.Api.Services.Interface; using Project.Api.Utilities; +using Project.Api.Utilities.Constants; using Project.Api.Utilities.Enums; namespace Project.Test.Tests.Services; @@ -17,6 +19,7 @@ public class BlackjackServiceTest private readonly Mock _roomPlayerRepositoryMock; private readonly Mock _handRepositoryMock; private readonly Mock _deckApiServiceMock; + private readonly Mock _roomSSEServiceMock; private readonly BlackjackService _blackjackService; public BlackjackServiceTest() @@ -25,13 +28,16 @@ public BlackjackServiceTest() _roomPlayerRepositoryMock = new Mock(); _handRepositoryMock = new Mock(); _deckApiServiceMock = new Mock(); + _roomSSEServiceMock = new Mock(); // Mocking IUserRepository is not needed for the betting action logic _blackjackService = new BlackjackService( _roomRepositoryMock.Object, _roomPlayerRepositoryMock.Object, new Mock().Object, _handRepositoryMock.Object, - _deckApiServiceMock.Object + _deckApiServiceMock.Object, + _roomSSEServiceMock.Object, + new Mock>().Object ); } @@ -107,14 +113,29 @@ public async Task PerformActionAsync_BetAction_Success_AfterDeadline_Transitions var actingPlayerId = Guid.NewGuid(); var otherPlayerId = Guid.NewGuid(); var betAmount = 100L; + var deckId = "test_deck_id"; var actingPlayer = new RoomPlayer { Id = actingPlayerId, + UserId = actingPlayerId, // Ensure UserId is set for GetByRoomIdAndUserIdAsync Balance = 1000, Status = Status.Away, }; - var otherPlayer = new RoomPlayer { Id = otherPlayerId, Balance = 1000 }; + var otherPlayer = new RoomPlayer + { + Id = otherPlayerId, + UserId = otherPlayerId, // Ensure UserId is set + Balance = 1000, + }; + + var room = new Room + { + Id = roomId, + DeckId = deckId, + GameMode = GameModes.Blackjack, + GameState = "", + }; var bettingStage = new BlackjackBettingStage( DateTimeOffset.UtcNow.AddMinutes(-1), @@ -133,6 +154,32 @@ public async Task PerformActionAsync_BetAction_Success_AfterDeadline_Transitions _roomPlayerRepositoryMock .Setup(r => r.GetByIdAsync(otherPlayer.Id)) .ReturnsAsync(otherPlayer); + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); // Add this line + _deckApiServiceMock + .Setup(d => d.DrawCards(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync( + [ + new() + { + Code = "C1", + Suit = "CLUBS", + Value = "ACE", + }, + ] + ); + _handRepositoryMock + .Setup(h => h.GetHandsByRoomIdAsync(roomId)) + .ReturnsAsync( + [ + new Hand + { + Id = Guid.NewGuid(), + RoomPlayerId = actingPlayerId, + Order = 0, + Bet = betAmount, + }, + ] + ); // Act await _blackjackService.PerformActionAsync( @@ -175,7 +222,11 @@ public async Task PerformActionAsync_InvalidActionForStage_ThrowsBadRequestExcep var roomId = Guid.NewGuid(); var gameState = new BlackjackState { - CurrentStage = new BlackjackPlayerActionStage(DateTimeOffset.UtcNow.AddMinutes(1), 0), + CurrentStage = new BlackjackPlayerActionStage( + DateTimeOffset.UtcNow.AddMinutes(1), + 0, + 0 + ), }; var gameStateString = JsonSerializer.Serialize(gameState); From 575620995abf2c5760e865884369a55da6d8be50 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 02:55:09 -0400 Subject: [PATCH 03/18] Fix incorrect error message --- Project.App/Project.Api/Services/BlackjackService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index d109630..71a5bba 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -485,7 +485,7 @@ long amount if (stage.HandIndex != 0 || handCards.Count != 2) { - throw new BadRequestException("Double can only be done on the player's first turn."); + throw new BadRequestException("Split can only be done on the player's first turn."); } // check if both cards are the same value @@ -545,7 +545,7 @@ private async Task DoSurrenderAsync(BlackjackState state, Guid roomId, RoomPlaye /// private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) { - if (state.CurrentStage is not BlackjackPlayerActionStage playerActionStage) + if (state.CurrentStage is not BlackjackPlayerActionStage) { throw new InvalidOperationException( "Cannot move to next hand when not in player action stage." From ef140c15486c60a3835c3d4e18609d49fd4f9528 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 02:57:06 -0400 Subject: [PATCH 04/18] Fix missing state transition --- Project.App/Project.Api/Services/BlackjackService.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 71a5bba..71500fd 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -570,6 +570,8 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) stage.PlayerIndex, stage.HandIndex + 1 ); + await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + return; } catch (NotFoundException) { From 52a0f745efef3f1f81bf9f1f4c9f888fe201e442 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 02:59:31 -0400 Subject: [PATCH 05/18] Remove unused user repository --- Project.App/Project.Api/Services/BlackjackService.cs | 4 +--- Project.App/Project.Test/Services/BlackjackServiceTest.cs | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 71500fd..c561c40 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -73,7 +73,6 @@ have the initial request handler that started the betting phase start a timer an public class BlackjackService( IRoomRepository roomRepository, IRoomPlayerRepository roomPlayerRepository, - IUserRepository userRepository, IHandRepository handRepository, IDeckApiService deckApiService, IRoomSSEService roomSSEService, @@ -82,11 +81,10 @@ ILogger logger { private readonly IRoomRepository _roomRepository = roomRepository; private readonly IRoomPlayerRepository _roomPlayerRepository = roomPlayerRepository; - private readonly IUserRepository _userRepository = userRepository; private readonly IHandRepository _handRepository = handRepository; private readonly IDeckApiService _deckApiService = deckApiService; private readonly IRoomSSEService _roomSSEService = roomSSEService; - private readonly ILogger _logger = logger; + private readonly ILogger _logger = logger; // TODO: add more logging private BlackjackConfig _config = new(); public BlackjackConfig Config diff --git a/Project.App/Project.Test/Services/BlackjackServiceTest.cs b/Project.App/Project.Test/Services/BlackjackServiceTest.cs index 21ca5ec..141bccc 100644 --- a/Project.App/Project.Test/Services/BlackjackServiceTest.cs +++ b/Project.App/Project.Test/Services/BlackjackServiceTest.cs @@ -11,7 +11,7 @@ using Project.Api.Utilities.Constants; using Project.Api.Utilities.Enums; -namespace Project.Test.Tests.Services; +namespace Project.Test.Services; public class BlackjackServiceTest { @@ -29,11 +29,10 @@ public BlackjackServiceTest() _handRepositoryMock = new Mock(); _deckApiServiceMock = new Mock(); _roomSSEServiceMock = new Mock(); - // Mocking IUserRepository is not needed for the betting action logic + _blackjackService = new BlackjackService( _roomRepositoryMock.Object, _roomPlayerRepositoryMock.Object, - new Mock().Object, _handRepositoryMock.Object, _deckApiServiceMock.Object, _roomSSEServiceMock.Object, From 9e26d748a32c75a5d3e6acad6d02d5a9a1d6741b Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 03:51:06 -0400 Subject: [PATCH 06/18] Modify SSE service to use type-safe events --- .../Project.Api/Controllers/RoomController.cs | 28 ++++--- Project.App/Project.Api/DTOs/CardDTO.cs | 1 + .../Project.Api/Models/Games/RoomEvents.cs | 73 +++++++++++++++++++ .../Services/Interface/IRoomSSEService.cs | 5 +- .../Project.Api/Services/RoomSSEService.cs | 8 +- .../Utilities/Enums/RoomEventType.cs | 12 +++ .../Integration/RoomSSEIntegrationTests.cs | 67 ++++++++++++++--- .../Services/RoomSSEServiceTests.cs | 43 +++++++---- 8 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 Project.App/Project.Api/Models/Games/RoomEvents.cs create mode 100644 Project.App/Project.Api/Utilities/Enums/RoomEventType.cs diff --git a/Project.App/Project.Api/Controllers/RoomController.cs b/Project.App/Project.Api/Controllers/RoomController.cs index 67d2d6c..174f71a 100644 --- a/Project.App/Project.Api/Controllers/RoomController.cs +++ b/Project.App/Project.Api/Controllers/RoomController.cs @@ -2,8 +2,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Project.Api.DTOs; +using Project.Api.Models.Games; using Project.Api.Services.Interface; using Project.Api.Utilities; +using Project.Api.Utilities.Enums; namespace Project.Api.Controllers; @@ -46,9 +48,9 @@ public async Task>> GetPublicRooms() [HttpGet("{id}")] public async Task> GetRoomById(Guid id) { - var room = await _roomService.GetRoomByIdAsync(id); - if (room == null) - throw new NotFoundException($"Room with ID {id} not found"); + var room = + await _roomService.GetRoomByIdAsync(id) + ?? throw new NotFoundException($"Room with ID {id} not found"); return Ok(room); } @@ -56,9 +58,9 @@ public async Task> GetRoomById(Guid id) [HttpGet("host/{hostId}")] public async Task> GetRoomByHostId(Guid hostId) { - var room = await _roomService.GetRoomByHostIdAsync(hostId); - if (room == null) - throw new NotFoundException($"Room with host ID {hostId} not found"); + var room = + await _roomService.GetRoomByHostIdAsync(hostId) + ?? throw new NotFoundException($"Room with host ID {hostId} not found"); return Ok(room); } @@ -107,9 +109,9 @@ public async Task> UpdateRoom(Guid id, [FromBody] UpdateRo if (!ModelState.IsValid) return BadRequest(ModelState); - var room = await _roomService.UpdateRoomAsync(dto); - if (room == null) - throw new NotFoundException($"Room with ID {id} not found"); + var room = + await _roomService.UpdateRoomAsync(dto) + ?? throw new NotFoundException($"Room with ID {id} not found"); return Ok(room); } @@ -242,9 +244,13 @@ public async Task BroadcastMessage(Guid roomId, [FromBody] Messag throw new BadRequestException("Message content cannot be empty."); } - string name = User.Identity?.Name ?? "Anonymous"; + MessageEventData data = new() + { + Sender = User.Identity?.Name ?? "Anonymous", + Content = message.Content, + }; - await _roomSSEService.BroadcastEventAsync(roomId, "message", $"{name}: {message.Content}"); + await _roomSSEService.BroadcastEventAsync(roomId, RoomEventType.Message, data); return Ok(); } diff --git a/Project.App/Project.Api/DTOs/CardDTO.cs b/Project.App/Project.Api/DTOs/CardDTO.cs index 92b8631..d028f98 100644 --- a/Project.App/Project.Api/DTOs/CardDTO.cs +++ b/Project.App/Project.Api/DTOs/CardDTO.cs @@ -6,4 +6,5 @@ public class CardDTO public string Image { get; set; } = string.Empty; public string Value { get; set; } = string.Empty; public string Suit { get; set; } = string.Empty; + public bool IsFaceDown { get; set; } = false; } diff --git a/Project.App/Project.Api/Models/Games/RoomEvents.cs b/Project.App/Project.Api/Models/Games/RoomEvents.cs new file mode 100644 index 0000000..d372282 --- /dev/null +++ b/Project.App/Project.Api/Models/Games/RoomEvents.cs @@ -0,0 +1,73 @@ +using Project.Api.DTOs; + +namespace Project.Api.Models.Games; + +/// +/// Base interface for broadcast game events +/// +public interface IRoomEventData { } + +/// +/// Specific DTO for a chat message event +/// +public class MessageEventData : IRoomEventData +{ + public string Sender { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} + +/// +/// Specific DTO for a game state update event +/// +public record GameStateUpdateEventData : IRoomEventData +{ + public string GameStateJson { get; set; } = string.Empty; + public int Version { get; set; } = 0; +} + +/// +/// Specific DTO for a player action event +/// +public record PlayerActionEventData : IRoomEventData +{ + public Guid PlayerId { get; set; } + public string Action { get; set; } = string.Empty; + public object? ActionDetails { get; set; } // e.g., BetAction, HitAction details +} + +/// +/// Specific DTO for a player join event +/// +public record PlayerJoinEventData : IRoomEventData +{ + public Guid PlayerId { get; set; } + public string PlayerName { get; set; } = string.Empty; +} + +/// +/// Specific DTO for a player leave event +/// +public record PlayerLeaveEventData : IRoomEventData +{ + public Guid PlayerId { get; set; } + public string PlayerName { get; set; } = string.Empty; +} + +/// +/// Specific DTO for revealing the dealer's cards +/// +public record DealerRevealEventData : IRoomEventData +{ + public List DealerHand { get; set; } = []; + public int DealerScore { get; set; } +} + +/// +/// Specific DTO for revealing scores at the end of a round +/// +public record PlayerRevealEventData : IRoomEventData +{ + public List PlayerHand { get; set; } = []; + public int PlayerScore { get; set; } +} diff --git a/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs b/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs index a9796bf..e11d456 100644 --- a/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs +++ b/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs @@ -1,3 +1,6 @@ +using Project.Api.Models.Games; +using Project.Api.Utilities.Enums; + namespace Project.Api.Services.Interface; public interface IRoomSSEService @@ -10,5 +13,5 @@ public interface IRoomSSEService /// /// Broadcasts an event to all clients connected to a specific room. /// - Task BroadcastEventAsync(Guid roomId, string eventName, object data); + Task BroadcastEventAsync(Guid roomId, RoomEventType eventName, IRoomEventData data); } diff --git a/Project.App/Project.Api/Services/RoomSSEService.cs b/Project.App/Project.Api/Services/RoomSSEService.cs index dffae98..8168100 100644 --- a/Project.App/Project.Api/Services/RoomSSEService.cs +++ b/Project.App/Project.Api/Services/RoomSSEService.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Text.Json; +using Project.Api.Models.Games; using Project.Api.Services.Interface; +using Project.Api.Utilities.Enums; namespace Project.Api.Services; @@ -51,7 +53,7 @@ public async Task AddConnectionAsync(Guid roomId, HttpResponse response) } } - public async Task BroadcastEventAsync(Guid roomId, string eventName, object data) + public async Task BroadcastEventAsync(Guid roomId, RoomEventType eventType, IRoomEventData data) { // check if room exists in connections if ( @@ -64,7 +66,9 @@ out ConcurrentDictionary? connections return; } - string eventPayload = $"event: {eventName}\ndata: {JsonSerializer.Serialize(data)}\n\n"; + // broadcast event using lowercase event type + string eventPayload = + $"event: {eventType.ToString().ToLowerInvariant()}\ndata: {JsonSerializer.Serialize(data, data.GetType())}\n\n"; List closedConnections = []; foreach ((string connectionId, StreamWriter writer) in connections) diff --git a/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs b/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs new file mode 100644 index 0000000..bec83bf --- /dev/null +++ b/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs @@ -0,0 +1,12 @@ +namespace Project.Api.Utilities.Enums; + +public enum RoomEventType +{ + Message, + GameStateUpdate, + PlayerAction, + PlayerJoined, + PlayerLeft, + DealerReveal, + PlayerReveal, +} diff --git a/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs b/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs index 8bd1e4f..cc269d9 100644 --- a/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs +++ b/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs @@ -7,6 +7,7 @@ using Moq; using Project.Api; using Project.Api.DTOs; +using Project.Api.Models.Games; // Add this using directive for MessageEventData using Project.Api.Services; using Project.Api.Services.Interface; using Project.Test.Helpers; @@ -77,8 +78,15 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_SingleClient() var messageContent = "Hello everyone!"; var message = new MessageDTO(messageContent); - var expectedMessage = $"Anonymous: {messageContent}"; - var expectedData = JsonSerializer.Serialize(expectedMessage); + + // Update expectedData to serialize a MessageEventData object + var expectedMessageEventData = new MessageEventData + { + Sender = "Anonymous", // Assuming the controller sets this + Content = messageContent, + // Timestamp will be set by the service, so we can't assert an exact value. + // We'll deserialize and check properties instead of direct string comparison. + }; // Act: Send a chat message var postResponse = await client.PostAsJsonAsync($"/api/room/{roomId}/chat", message); @@ -89,7 +97,18 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_SingleClient() Assert.Equal("event: message", eventLine); string dataLine = await reader.ReadLineAsync() ?? "No line received!"; - Assert.Equal($"data: {expectedData}", dataLine); + Assert.StartsWith("data: ", dataLine); // Check prefix + + // Deserialize the actual data received + var receivedDataJson = dataLine.Substring("data: ".Length); + var receivedMessageEventData = JsonSerializer.Deserialize( + receivedDataJson + ); + + Assert.NotNull(receivedMessageEventData); + Assert.Equal(expectedMessageEventData.Sender, receivedMessageEventData.Sender); + Assert.Equal(expectedMessageEventData.Content, receivedMessageEventData.Content); + Assert.NotEqual(default, receivedMessageEventData.Timestamp); // Ensure timestamp was set string blankLine = await reader.ReadLineAsync() ?? "No line received!"; Assert.True(string.IsNullOrEmpty(blankLine)); @@ -112,8 +131,13 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_MultipleClientsInS var messageContent = "Group chat!"; var message = new MessageDTO(messageContent); - var expectedMessage = $"Anonymous: {messageContent}"; - var expectedData = JsonSerializer.Serialize(expectedMessage); + + // Update expectedData to serialize a MessageEventData object + var expectedMessageEventData = new MessageEventData + { + Sender = "Anonymous", + Content = messageContent, + }; // Act: Send a chat message var postResponse = await client1.PostAsJsonAsync($"/api/room/{roomId}/chat", message); @@ -123,13 +147,25 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_MultipleClientsInS string eventLine1 = await reader1.ReadLineAsync() ?? "No line received!"; Assert.Equal("event: message", eventLine1); string dataLine1 = await reader1.ReadLineAsync() ?? "No line received!"; - Assert.Equal($"data: {expectedData}", dataLine1); + Assert.StartsWith("data: ", dataLine1); + var receivedMessageEventData1 = JsonSerializer.Deserialize( + dataLine1.Substring("data: ".Length) + ); + Assert.NotNull(receivedMessageEventData1); + Assert.Equal(expectedMessageEventData.Sender, receivedMessageEventData1.Sender); + Assert.Equal(expectedMessageEventData.Content, receivedMessageEventData1.Content); await reader1.ReadLineAsync(); // Blank line string eventLine2 = await reader2.ReadLineAsync() ?? "No line received!"; Assert.Equal("event: message", eventLine2); string dataLine2 = await reader2.ReadLineAsync() ?? "No line received!"; - Assert.Equal($"data: {expectedData}", dataLine2); + Assert.StartsWith("data: ", dataLine2); + var receivedMessageEventData2 = JsonSerializer.Deserialize( + dataLine2.Substring("data: ".Length) + ); + Assert.NotNull(receivedMessageEventData2); + Assert.Equal(expectedMessageEventData.Sender, receivedMessageEventData2.Sender); + Assert.Equal(expectedMessageEventData.Content, receivedMessageEventData2.Content); await reader2.ReadLineAsync(); // Blank line // Clean up @@ -153,8 +189,13 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_ClientsInDifferent var messageContent = "Only for room 1!"; var message = new MessageDTO(messageContent); - var expectedMessage = $"Anonymous: {messageContent}"; - var expectedData = JsonSerializer.Serialize(expectedMessage); + + // Update expectedData to serialize a MessageEventData object + var expectedMessageEventData = new MessageEventData + { + Sender = "Anonymous", + Content = messageContent, + }; // Act: Send a chat message to room 1 var postResponse = await client1.PostAsJsonAsync($"/api/room/{roomId1}/chat", message); @@ -164,7 +205,13 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_ClientsInDifferent string eventLine1 = await reader1.ReadLineAsync() ?? "No line received!"; Assert.Equal("event: message", eventLine1); string dataLine1 = await reader1.ReadLineAsync() ?? "No line received!"; - Assert.Equal($"data: {expectedData}", dataLine1); + Assert.StartsWith("data: ", dataLine1); + var receivedMessageEventData1 = JsonSerializer.Deserialize( + dataLine1.Substring("data: ".Length) + ); + Assert.NotNull(receivedMessageEventData1); + Assert.Equal(expectedMessageEventData.Sender, receivedMessageEventData1.Sender); + Assert.Equal(expectedMessageEventData.Content, receivedMessageEventData1.Content); await reader1.ReadLineAsync(); // Blank line // Assert: Client in room 2 does NOT receive the event. diff --git a/Project.App/Project.Test/Services/RoomSSEServiceTests.cs b/Project.App/Project.Test/Services/RoomSSEServiceTests.cs index cefea07..cd9c422 100644 --- a/Project.App/Project.Test/Services/RoomSSEServiceTests.cs +++ b/Project.App/Project.Test/Services/RoomSSEServiceTests.cs @@ -1,7 +1,9 @@ using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; +using Project.Api.Models.Games; // Add this using directive for IRoomEventData and concrete types using Project.Api.Services; +using Project.Api.Utilities.Enums; // Add this using directive for RoomEventType namespace Project.Test.Services; @@ -73,13 +75,18 @@ public async Task BroadcastEventAsync_ShouldSendEventToAllClientsInRoom() // Allow connections to be established await Task.Delay(100); - var eventName = "player-joined"; - var eventData = new { PlayerId = "player123", Name = "John Doe" }; + // Use the new RoomEventType enum and PlayerJoinEventData DTO + var eventType = RoomEventType.PlayerJoined; + var eventData = new PlayerJoinEventData + { + PlayerId = Guid.NewGuid(), + PlayerName = "John Doe", + }; var expectedPayload = - $"event: {eventName}\ndata: {JsonSerializer.Serialize(eventData)}\n\n"; + $"event: {eventType.ToString().ToLowerInvariant()}\ndata: {JsonSerializer.Serialize(eventData)}\n\n"; // Act - await sseService.BroadcastEventAsync(roomId1, eventName, eventData); + await sseService.BroadcastEventAsync(roomId1, eventType, eventData); // Assert: Verify clients in room 1 received the event stream1.Position = 0; @@ -125,13 +132,14 @@ public async Task BroadcastEventAsync_ShouldCleanupDisconnectedClients() // Simulate one client disconnecting by disposing its response stream await stream2.DisposeAsync(); - var eventName = "test-event"; - var eventData = new { Message = "Hello" }; - var expectedPayload = - $"event: {eventName}\ndata: {JsonSerializer.Serialize(eventData)}\n\n"; + // Use the new RoomEventType enum and MessageEventData DTO + var eventType1 = RoomEventType.Message; + var eventData1 = new MessageEventData { Sender = "Test", Content = "Hello" }; + var expectedPayload1 = + $"event: {eventType1.ToString().ToLowerInvariant()}\ndata: {JsonSerializer.Serialize(eventData1)}\n\n"; // Act: first broadcast - await sseService.BroadcastEventAsync(roomId, eventName, eventData); + await sseService.BroadcastEventAsync(roomId, eventType1, eventData1); // Assert: The open connection (stream1) received the event // Reset position to 0, then read all content @@ -141,20 +149,21 @@ public async Task BroadcastEventAsync_ShouldCleanupDisconnectedClients() // Skip the “: connected” line var _ = await reader1.ReadLineAsync(); var payload1 = await reader1.ReadToEndAsync(); - Assert.Equal(expectedPayload, payload1); + Assert.Equal(expectedPayload1, payload1); } // Mark current length (position for next read) long afterFirstLength = stream1.Length; // Act: second broadcast - var secondEventData = new { Message = "Still here?" }; + var eventType2 = RoomEventType.Message; + var eventData2 = new MessageEventData { Sender = "Test", Content = "Still here?" }; var expectedPayload2 = - $"event: second-event\ndata: {JsonSerializer.Serialize(secondEventData)}\n\n"; - await sseService.BroadcastEventAsync(roomId, "second-event", secondEventData); + $"event: {eventType2.ToString().ToLowerInvariant()}\ndata: {JsonSerializer.Serialize(eventData2)}\n\n"; + await sseService.BroadcastEventAsync(roomId, eventType2, eventData2); // Assert: only the open connection receives the second event - // Set position to the book‑marked point so we only read the newly written part + // Set position to the book-marked point so we only read the newly written part stream1.Position = afterFirstLength; using (var reader2 = new StreamReader(stream1, Encoding.UTF8, leaveOpen: true)) { @@ -175,9 +184,13 @@ public async Task BroadcastEventAsync_ShouldDoNothingForRoomWithNoConnections() var sseService = new RoomSSEService(); var roomId = Guid.NewGuid(); // A room with no connections + // Use the new RoomEventType enum and MessageEventData DTO + var eventType = RoomEventType.Message; + var eventData = new MessageEventData { Sender = "Test", Content = "empty" }; + // Act var exception = await Record.ExceptionAsync(() => - sseService.BroadcastEventAsync(roomId, "any-event", new { Data = "empty" }) + sseService.BroadcastEventAsync(roomId, eventType, eventData) ); // Assert From 71d6ed0a20a7e1e9d3f7d04871ebd1893f82e9a7 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 03:59:46 -0400 Subject: [PATCH 07/18] Implement blackjack event messaging (mostly untested) --- .../Project.Api/Services/BlackjackService.cs | 285 +++++++++++++++++- .../Services/BlackjackServiceTest.cs | 158 +++++++++- 2 files changed, 413 insertions(+), 30 deletions(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index c561c40..f3711ff 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -136,6 +136,13 @@ await _roomRepository.GetByIdAsync(roomId) room.GameState = JsonSerializer.Serialize(initialState); await _roomRepository.UpdateAsync(room); + + // Broadcast initial game state + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = room.GameState } + ); } public async Task PerformActionAsync( @@ -189,9 +196,12 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( _config.TurnTimeLimit ); - await _roomRepository.UpdateGameStateAsync( + string updatedGameState = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); + await _roomSSEService.BroadcastEventAsync( roomId, - JsonSerializer.Serialize(state) + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameState } ); } break; @@ -207,7 +217,8 @@ await _roomRepository.UpdateGameStateAsync( // next player or next stage await NextHandOrFinishRoundAsync(state, roomId); - throw new NotImplementedException(); + // throw new NotImplementedException(); // This line should be removed if DoDoubleAsync is fully implemented + break; case SplitAction splitAction: await DoSplitAsync(state, roomId, player, splitAction.Amount); @@ -215,7 +226,13 @@ await _roomRepository.UpdateGameStateAsync( ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( _config.TurnTimeLimit ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameStateSplit = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateSplit); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameStateSplit } + ); break; case SurrenderAction surrenderAction: await DoSurrenderAsync(state, roomId, player); @@ -245,6 +262,18 @@ .. await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId), player.Status = Status.Inactive; await _roomPlayerRepository.UpdateAsync(player); + // Broadcast player action + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = playerId, + Action = "hurry_up", + ActionDetails = null, + } + ); + // act as if the player stood await NextHandOrFinishRoundAsync(state, roomId); break; @@ -280,11 +309,31 @@ long bet // set bet in gamestate stage.Bets[player.Id] = bet; - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameState = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); // update player status player.Status = Status.Active; await _roomPlayerRepository.UpdateAsync(player); + + // Broadcast player action (bet) + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = player.Id, + Action = "bet", + ActionDetails = new { Amount = bet }, + } + ); + + // Broadcast game state update + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameState } + ); } /// @@ -298,7 +347,13 @@ private async Task StartRoundAsync(BlackjackState state, Guid roomId) // move to dealing stage state.CurrentStage = new BlackjackDealingStage(); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameStateDealing = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateDealing); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameStateDealing } + ); // deduct bets from player balances and initialize hands int order = 0; @@ -354,13 +409,67 @@ await _roomRepository.GetByIdAsync(roomId) await _deckApiService.DrawCards(deckId, "dealer", 1); } + // After dealing, broadcast initial hands + foreach (Hand hand in hands.OrderBy(h => h.Order)) + { + List playerHandCards = await _deckApiService.ListHand( + deckId, + $"hand-{hand.Id}" + ); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerHand = playerHandCards, + PlayerScore = CalculateHandValue(playerHandCards), + } + ); + } + + List dealerInitialHand = await _deckApiService.ListHand(deckId, "dealer"); + // For initial deal, one dealer card is face down + List dealerRevealedHand = new List(); + if (dealerInitialHand.Count > 0) + { + dealerRevealedHand.Add(dealerInitialHand[0]); // First card face up + if (dealerInitialHand.Count > 1) + { + dealerRevealedHand.Add( + new CardDTO + { + Code = "FACEDOWN", + Image = "facedown_card_image_url", // Placeholder for a face-down card image + Value = string.Empty, + Suit = string.Empty, + IsFaceDown = true, + } + ); // Second card face down + } + } + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.DealerReveal, + new DealerRevealEventData + { + DealerHand = dealerRevealedHand, + DealerScore = CalculateHandValue(new List { dealerInitialHand[0] }), // Only score the visible card + } + ); + // move to player action stage state.CurrentStage = new BlackjackPlayerActionStage( DateTimeOffset.UtcNow + _config.TurnTimeLimit, 0, 0 ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameStatePlayerAction = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStatePlayerAction); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameStatePlayerAction } + ); } /// @@ -412,10 +521,34 @@ private async Task DoHitAsync(BlackjackState state, Guid roomId, RoomPlaye await InitializePlayerActionAsync(state, roomId, player); // draw one card and add it to player's hand - List handCards = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + List drawnCards = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + List playerHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); + + // Broadcast player action (hit) + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = player.Id, + Action = "hit", + ActionDetails = new { DrawnCard = drawnCards.FirstOrDefault() }, + } + ); + + // Broadcast updated player hand + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerHand = playerHandCards, + PlayerScore = CalculateHandValue(playerHandCards), + } + ); // check if player busted - int totalValue = CalculateHandValue(handCards); + int totalValue = CalculateHandValue(playerHandCards); return totalValue > 21; } @@ -452,7 +585,31 @@ private async Task DoDoubleAsync(BlackjackState state, Guid roomId, RoomPlayer p await _handRepository.UpdateHandAsync(hand.Id, hand); // draw one card and add it to player's hand - await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + List drawnCards = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + List playerHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); + + // Broadcast player action (double) + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = player.Id, + Action = "double", + ActionDetails = new { Amount = hand.Bet, DrawnCard = drawnCards.FirstOrDefault() }, + } + ); + + // Broadcast updated player hand + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerHand = playerHandCards, + PlayerScore = CalculateHandValue(playerHandCards), + } + ); } /// @@ -514,11 +671,50 @@ await _handRepository.CreateHandAsync( await _deckApiService.AddToHand(deckId, $"hand-{newHand.Id}", cardToMove.Code); // draw one card for each hand - await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); - await _deckApiService.DrawCards(deckId, $"hand-{newHand.Id}", 1); + List drawnCard1 = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + List drawnCard2 = await _deckApiService.DrawCards(deckId, $"hand-{newHand.Id}", 1); // deduct bet from player's balance await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, -amount); + + // Broadcast player action (split) + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = player.Id, + Action = "split", + ActionDetails = new + { + OriginalHandId = hand.Id, + NewHandId = newHand.Id, + BetAmount = amount, + }, + } + ); + + // Broadcast updated player hands + List originalHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerHand = originalHandCards, + PlayerScore = CalculateHandValue(originalHandCards), + } + ); + List newHandCards = await _deckApiService.ListHand(deckId, $"hand-{newHand.Id}"); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerHand = newHandCards, + PlayerScore = CalculateHandValue(newHandCards), + } + ); } /// @@ -536,6 +732,18 @@ private async Task DoSurrenderAsync(BlackjackState state, Guid roomId, RoomPlaye // refund half of player's bet (add to balance and update gamestate) await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, hands[0].Bet / 2); + + // Broadcast player action (surrender) + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = player.Id, + Action = "surrender", + ActionDetails = new { RefundAmount = hands[0].Bet / 2 }, + } + ); } /// @@ -568,7 +776,13 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) stage.PlayerIndex, stage.HandIndex + 1 ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameState = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameState } + ); return; } catch (NotFoundException) @@ -592,7 +806,13 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) nextPlayerIndex, 0 ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameState = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameState } + ); return; } catch (NotFoundException) @@ -615,7 +835,13 @@ private async Task FinishRoundAsync(BlackjackState state, Guid roomId) { // transition to finish round stage state.CurrentStage = new BlackjackFinishRoundStage(); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameStateFinish = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateFinish); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameStateFinish } + ); Room room = await _roomRepository.GetByIdAsync(roomId) @@ -638,6 +864,13 @@ await _roomRepository.GetByIdAsync(roomId) dealerValue = CalculateHandValue(dealerHand); } + // Broadcast final dealer hand (all cards face up) + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.DealerReveal, + new DealerRevealEventData { DealerHand = dealerHand, DealerScore = dealerValue } + ); + // calculate winnings for each player hand List hands = await _handRepository.GetHandsByRoomIdAsync(roomId); foreach (Hand hand in hands) @@ -668,6 +901,17 @@ await _roomPlayerRepository.UpdatePlayerBalanceAsync( // dealer wins, do nothing break; } + + // Broadcast final player hand and score + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerHand = playerHand, + PlayerScore = CalculateHandValue(playerHand), + } + ); } // reset player hands @@ -689,7 +933,13 @@ await _roomPlayerRepository.UpdatePlayerBalanceAsync( DateTimeOffset.UtcNow + _config.BettingTimeLimit, [] ); - await _roomRepository.UpdateGameStateAsync(roomId, JsonSerializer.Serialize(state)); + string updatedGameStateNextRound = JsonSerializer.Serialize(state); + await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateNextRound); + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { GameStateJson = updatedGameStateNextRound } + ); } static int CalculateHandValue(List hand, int target = 21) @@ -699,6 +949,9 @@ static int CalculateHandValue(List hand, int target = 21) foreach (CardDTO card in hand) { + if (card.IsFaceDown) + continue; // Do not count face-down cards for score calculation + switch (card.Value.ToUpper()) { case "ACE": diff --git a/Project.App/Project.Test/Services/BlackjackServiceTest.cs b/Project.App/Project.Test/Services/BlackjackServiceTest.cs index 141bccc..62b8b68 100644 --- a/Project.App/Project.Test/Services/BlackjackServiceTest.cs +++ b/Project.App/Project.Test/Services/BlackjackServiceTest.cs @@ -153,33 +153,113 @@ public async Task PerformActionAsync_BetAction_Success_AfterDeadline_Transitions _roomPlayerRepositoryMock .Setup(r => r.GetByIdAsync(otherPlayer.Id)) .ReturnsAsync(otherPlayer); - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); // Add this line + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); + + // Define card DTOs for clarity and to ensure non-null values + var player1Card1 = new CardDTO + { + Code = "H2", + Value = "2", + Suit = "HEARTS", + }; + var player2Card1 = new CardDTO + { + Code = "D3", + Value = "3", + Suit = "DIAMONDS", + }; + var dealerCard1 = new CardDTO + { + Code = "S4", + Value = "4", + Suit = "SPADES", + }; + var player1Card2 = new CardDTO + { + Code = "C5", + Value = "5", + Suit = "CLUBS", + }; + var player2Card2 = new CardDTO + { + Code = "H6", + Value = "6", + Suit = "HEARTS", + }; + var dealerCard2 = new CardDTO + { + Code = "SK", + Value = "KING", + Suit = "SPADES", + }; + + // Mock DrawCards to return specific cards for the initial deal sequence + // (2 rounds * (2 players + 1 dealer) = 6 calls) _deckApiServiceMock - .Setup(d => d.DrawCards(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync( - [ - new() - { - Code = "C1", - Suit = "CLUBS", - Value = "ACE", - }, - ] - ); + .SetupSequence(d => d.DrawCards(It.IsAny(), It.IsAny(), 1)) + .ReturnsAsync([player1Card1]) // Round 1: Player 1 + .ReturnsAsync([player2Card1]) // Round 1: Player 2 + .ReturnsAsync([dealerCard1]) // Round 1: Dealer + .ReturnsAsync([player1Card2]) // Round 2: Player 1 + .ReturnsAsync([player2Card2]) // Round 2: Player 2 + .ReturnsAsync([dealerCard2]); // Round 2: Dealer + + // Setup hand IDs for the hands that will be created + var actingPlayerHandId = Guid.NewGuid(); + var otherPlayerHandId = Guid.NewGuid(); + + // Mock CreateHandAsync to capture the created hand IDs and orders + _handRepositoryMock + .Setup(h => h.CreateHandAsync(It.IsAny())) + .Callback(hand => + { + if (hand.RoomPlayerId == actingPlayer.Id) + { + hand.Id = actingPlayerHandId; + hand.Order = 0; // Assuming acting player is the first hand + } + else if (hand.RoomPlayerId == otherPlayer.Id) + { + hand.Id = otherPlayerHandId; + hand.Order = 1; // Assuming other player is the second hand + } + }); + + // Mock GetHandsByRoomIdAsync to return the expected hands after creation _handRepositoryMock .Setup(h => h.GetHandsByRoomIdAsync(roomId)) .ReturnsAsync( [ new Hand { - Id = Guid.NewGuid(), - RoomPlayerId = actingPlayerId, + Id = actingPlayerHandId, + RoomPlayerId = actingPlayer.Id, Order = 0, Bet = betAmount, }, + new Hand + { + Id = otherPlayerHandId, + RoomPlayerId = otherPlayer.Id, + Order = 1, + Bet = 50L, // Bet from otherPlayer in bettingStage + }, ] ); + // Mock ListHand to return the full hands after dealing for each player and dealer + _deckApiServiceMock + .Setup(d => d.ListHand(deckId, $"hand-{actingPlayerHandId}")) + .ReturnsAsync([player1Card1, player1Card2]); + + _deckApiServiceMock + .Setup(d => d.ListHand(deckId, $"hand-{otherPlayerHandId}")) + .ReturnsAsync([player2Card1, player2Card2]); + + _deckApiServiceMock + .Setup(d => d.ListHand(deckId, "dealer")) + .ReturnsAsync([dealerCard1, dealerCard2]); + // Act await _blackjackService.PerformActionAsync( roomId, @@ -212,6 +292,56 @@ .CurrentStage is BlackjackPlayerActionStage ), Times.Once ); + + // Verify SSE events for game state updates + _roomSSEServiceMock.Verify( + s => + s.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + It.IsAny() + ), + Times.AtLeastOnce + ); + + // Verify SSE events for player reveals (for both players) + _roomSSEServiceMock.Verify( + s => + s.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + It.Is(data => + data.PlayerHand.SequenceEqual(new[] { player1Card1, player1Card2 }) + ) + ), + Times.Once + ); + _roomSSEServiceMock.Verify( + s => + s.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + It.Is(data => + data.PlayerHand.SequenceEqual(new[] { player2Card1, player2Card2 }) + ) + ), + Times.Once + ); + + // Verify SSE event for dealer reveal (initial, one card facedown) + _roomSSEServiceMock.Verify( + s => + s.BroadcastEventAsync( + roomId, + RoomEventType.DealerReveal, + It.Is(data => + data.DealerHand.Count == 2 + && data.DealerHand[0].Code == dealerCard1.Code + && data.DealerHand[1].IsFaceDown == true + ) + ), + Times.Once + ); } [Fact] From a049dce4950a70a003d08f9374984d857e6cbc80 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 11:43:37 -0400 Subject: [PATCH 08/18] Implement blackjackService messaging functionality --- .../Models/Games/BlackjackState.cs | 1 - .../Project.Api/Models/Games/RoomEvents.cs | 16 +- .../Project.Api/Services/BlackjackService.cs | 445 ++++++------------ .../Project.Api/Services/DeckApiService.cs | 21 +- .../Extensions/BlackjackCardExtensions.cs | 106 +++++ .../Utilities/Extensions/RoomSSEExtensions.cs | 95 ++++ .../Services/DeckApiServiceTest.cs | 72 ++- 7 files changed, 411 insertions(+), 345 deletions(-) create mode 100644 Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs create mode 100644 Project.App/Project.Api/Utilities/Extensions/RoomSSEExtensions.cs diff --git a/Project.App/Project.Api/Models/Games/BlackjackState.cs b/Project.App/Project.Api/Models/Games/BlackjackState.cs index 02b9df7..c374a90 100644 --- a/Project.App/Project.Api/Models/Games/BlackjackState.cs +++ b/Project.App/Project.Api/Models/Games/BlackjackState.cs @@ -32,7 +32,6 @@ public record BlackjackBettingStage(DateTimeOffset Deadline, Dictionary public record GameStateUpdateEventData : IRoomEventData { - public string GameStateJson { get; set; } = string.Empty; - public int Version { get; set; } = 0; + public required BlackjackStage CurrentStage { get; set; } } /// -/// Specific DTO for a player action event +/// Specific DTO for a player action event. +/// Provides some context about the action taken by a player that any action might use. /// public record PlayerActionEventData : IRoomEventData { public Guid PlayerId { get; set; } + public int HandIndex { get; set; } = 0; public string Action { get; set; } = string.Empty; - public object? ActionDetails { get; set; } // e.g., BetAction, HitAction details + + // TODO: make event data type-safe + public long? Amount { get; set; } = 0; + public List? Cards { get; set; } + public Guid? TargetPlayerId { get; set; } + public bool? Success { get; set; } = true; } /// @@ -68,6 +74,8 @@ public record DealerRevealEventData : IRoomEventData /// public record PlayerRevealEventData : IRoomEventData { + public Guid PlayerId { get; set; } + public int HandIndex { get; set; } = 0; public List PlayerHand { get; set; } = []; public int PlayerScore { get; set; } } diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index f3711ff..f575c07 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Microsoft.AspNetCore.Http.HttpResults; using Project.Api.DTOs; using Project.Api.Models; using Project.Api.Models.Games; @@ -137,12 +136,8 @@ await _roomRepository.GetByIdAsync(roomId) await _roomRepository.UpdateAsync(room); - // Broadcast initial game state - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = room.GameState } - ); + // broadcast initial game state, do not save (since it's already saved) + await initialState.SaveStateAndBroadcastAsync(roomId, roomSSEService: _roomSSEService); } public async Task PerformActionAsync( @@ -196,12 +191,10 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( _config.TurnTimeLimit ); - string updatedGameState = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); - await _roomSSEService.BroadcastEventAsync( + await state.SaveStateAndBroadcastAsync( roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameState } + _roomRepository, + _roomSSEService ); } break; @@ -226,21 +219,15 @@ await _roomSSEService.BroadcastEventAsync( ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( _config.TurnTimeLimit ); - string updatedGameStateSplit = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateSplit); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameStateSplit } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); break; - case SurrenderAction surrenderAction: + case SurrenderAction: await DoSurrenderAsync(state, roomId, player); // next player or next stage await NextHandOrFinishRoundAsync(state, roomId); break; - case HurryUpAction hurryUpAction: + case HurryUpAction: switch (state.CurrentStage) { case BlackjackBettingStage: @@ -257,22 +244,49 @@ .. await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId), // start the round await StartRoundAsync(state, roomId); break; - case BlackjackPlayerActionStage: - // mark player as inactive - player.Status = Status.Inactive; - await _roomPlayerRepository.UpdateAsync(player); - - // Broadcast player action - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.PlayerAction, - new PlayerActionEventData - { - PlayerId = playerId, - Action = "hurry_up", - ActionDetails = null, - } - ); + case BlackjackPlayerActionStage actionStage: + // check if deadline has passed + if (DateTime.UtcNow < actionStage.Deadline) + { + // deadline not passed, broadcast failed player action + await _roomSSEService.BroadcastPlayerActionAsync( + roomId, + playerId, + ((BlackjackPlayerActionStage)state.CurrentStage).HandIndex, + "hurry_up", + success: false + ); + } + else + { + // get (in)active player of the current stage + Guid inactiveRoomPlayerId = ( + await _handRepository.GetHandByRoomOrderAsync( + roomId, + actionStage.PlayerIndex, + actionStage.HandIndex + ) + ).RoomPlayerId; + RoomPlayer incativePlayer = + await _roomPlayerRepository.GetByIdAsync(inactiveRoomPlayerId) + ?? throw new InternalServerException( + $"Could not find current player {inactiveRoomPlayerId} in room {roomId}." + ); + + // deadline passed, mark player as inactive + incativePlayer.Status = Status.Inactive; + await _roomPlayerRepository.UpdateAsync(incativePlayer); + + // broadcast player action + await _roomSSEService.BroadcastPlayerActionAsync( + roomId, + playerId, + actionStage.HandIndex, + "hurry_up", + success: true, + targetPlayerId: incativePlayer.Id + ); + } // act as if the player stood await NextHandOrFinishRoundAsync(state, roomId); @@ -309,31 +323,15 @@ long bet // set bet in gamestate stage.Bets[player.Id] = bet; - string updatedGameState = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); // update player status player.Status = Status.Active; await _roomPlayerRepository.UpdateAsync(player); - // Broadcast player action (bet) - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.PlayerAction, - new PlayerActionEventData - { - PlayerId = player.Id, - Action = "bet", - ActionDetails = new { Amount = bet }, - } - ); + // broadcast player action (bet) + await _roomSSEService.BroadcastPlayerActionAsync(roomId, player.Id, 0, "bet", amount: bet); - // Broadcast game state update - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameState } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); } /// @@ -347,13 +345,7 @@ private async Task StartRoundAsync(BlackjackState state, Guid roomId) // move to dealing stage state.CurrentStage = new BlackjackDealingStage(); - string updatedGameStateDealing = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateDealing); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameStateDealing } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); // deduct bets from player balances and initialize hands int order = 0; @@ -409,53 +401,24 @@ await _roomRepository.GetByIdAsync(roomId) await _deckApiService.DrawCards(deckId, "dealer", 1); } - // After dealing, broadcast initial hands + // after dealing, broadcast initial player hands foreach (Hand hand in hands.OrderBy(h => h.Order)) { List playerHandCards = await _deckApiService.ListHand( deckId, $"hand-{hand.Id}" ); - await _roomSSEService.BroadcastEventAsync( + await playerHandCards.BroadcastAsync( + _roomSSEService, roomId, - RoomEventType.PlayerReveal, - new PlayerRevealEventData - { - PlayerHand = playerHandCards, - PlayerScore = CalculateHandValue(playerHandCards), - } - ); + hand.RoomPlayerId, + hand.HandNumber + ); // no hidden cards for players } - List dealerInitialHand = await _deckApiService.ListHand(deckId, "dealer"); - // For initial deal, one dealer card is face down - List dealerRevealedHand = new List(); - if (dealerInitialHand.Count > 0) - { - dealerRevealedHand.Add(dealerInitialHand[0]); // First card face up - if (dealerInitialHand.Count > 1) - { - dealerRevealedHand.Add( - new CardDTO - { - Code = "FACEDOWN", - Image = "facedown_card_image_url", // Placeholder for a face-down card image - Value = string.Empty, - Suit = string.Empty, - IsFaceDown = true, - } - ); // Second card face down - } - } - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.DealerReveal, - new DealerRevealEventData - { - DealerHand = dealerRevealedHand, - DealerScore = CalculateHandValue(new List { dealerInitialHand[0] }), // Only score the visible card - } - ); + // broadcast dealer's visible card (hide second card) + List dealerHandCards = await _deckApiService.ListHand(deckId, "dealer"); + await dealerHandCards.BroadcastAsync(_roomSSEService, roomId, hiddenIndices: [1]); // move to player action stage state.CurrentStage = new BlackjackPlayerActionStage( @@ -463,13 +426,7 @@ await _roomSSEService.BroadcastEventAsync( 0, 0 ); - string updatedGameStatePlayerAction = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStatePlayerAction); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameStatePlayerAction } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); } /// @@ -517,40 +474,31 @@ await _roomRepository.GetByIdAsync(roomId) /// true if player busted private async Task DoHitAsync(BlackjackState state, Guid roomId, RoomPlayer player) { - (BlackjackPlayerActionStage stage, Hand hand, string deckId) = - await InitializePlayerActionAsync(state, roomId, player); + (_, Hand hand, string deckId) = await InitializePlayerActionAsync(state, roomId, player); // draw one card and add it to player's hand List drawnCards = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); List playerHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); - // Broadcast player action (hit) - await _roomSSEService.BroadcastEventAsync( + // broadcast player action (hit) + await _roomSSEService.BroadcastPlayerActionAsync( roomId, - RoomEventType.PlayerAction, - new PlayerActionEventData - { - PlayerId = player.Id, - Action = "hit", - ActionDetails = new { DrawnCard = drawnCards.FirstOrDefault() }, - } + player.Id, + hand.HandNumber, + "hit", + cards: drawnCards ); - // Broadcast updated player hand - await _roomSSEService.BroadcastEventAsync( + // broadcast updated player hand + await playerHandCards.BroadcastAsync( + _roomSSEService, roomId, - RoomEventType.PlayerReveal, - new PlayerRevealEventData - { - PlayerHand = playerHandCards, - PlayerScore = CalculateHandValue(playerHandCards), - } + player.UserId, + hand.HandNumber ); - // check if player busted - int totalValue = CalculateHandValue(playerHandCards); - - return totalValue > 21; + // return if player busted + return playerHandCards.CalculateHandValue() > 21; } /// @@ -585,30 +533,25 @@ private async Task DoDoubleAsync(BlackjackState state, Guid roomId, RoomPlayer p await _handRepository.UpdateHandAsync(hand.Id, hand); // draw one card and add it to player's hand - List drawnCards = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + List drawnCard = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); List playerHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); - // Broadcast player action (double) - await _roomSSEService.BroadcastEventAsync( + // broadcast player action (double) + await _roomSSEService.BroadcastPlayerActionAsync( roomId, - RoomEventType.PlayerAction, - new PlayerActionEventData - { - PlayerId = player.Id, - Action = "double", - ActionDetails = new { Amount = hand.Bet, DrawnCard = drawnCards.FirstOrDefault() }, - } + player.Id, + hand.HandNumber, + "double", + cards: drawnCard, + amount: hand.Bet ); - // Broadcast updated player hand - await _roomSSEService.BroadcastEventAsync( + // broadcast updated player hand + await playerHandCards.BroadcastAsync( + _roomSSEService, roomId, - RoomEventType.PlayerReveal, - new PlayerRevealEventData - { - PlayerHand = playerHandCards, - PlayerScore = CalculateHandValue(playerHandCards), - } + player.UserId, + hand.HandNumber ); } @@ -671,56 +614,45 @@ await _handRepository.CreateHandAsync( await _deckApiService.AddToHand(deckId, $"hand-{newHand.Id}", cardToMove.Code); // draw one card for each hand - List drawnCard1 = await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); - List drawnCard2 = await _deckApiService.DrawCards(deckId, $"hand-{newHand.Id}", 1); + await _deckApiService.DrawCards(deckId, $"hand-{hand.Id}", 1); + await _deckApiService.DrawCards(deckId, $"hand-{newHand.Id}", 1); // deduct bet from player's balance await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, -amount); - // Broadcast player action (split) - await _roomSSEService.BroadcastEventAsync( + // broadcast player action (split) + await _roomSSEService.BroadcastPlayerActionAsync( roomId, - RoomEventType.PlayerAction, - new PlayerActionEventData - { - PlayerId = player.Id, - Action = "split", - ActionDetails = new - { - OriginalHandId = hand.Id, - NewHandId = newHand.Id, - BetAmount = amount, - }, - } - ); - - // Broadcast updated player hands - List originalHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); - await _roomSSEService.BroadcastEventAsync( + player.Id, + hand.HandNumber, + "split", + amount: amount + ); // can't really broadcast both hands in one event + + // broadcast both updated player hands + List firstHandCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); + await firstHandCards.BroadcastAsync( + _roomSSEService, roomId, - RoomEventType.PlayerReveal, - new PlayerRevealEventData - { - PlayerHand = originalHandCards, - PlayerScore = CalculateHandValue(originalHandCards), - } + player.UserId, + hand.HandNumber + ); + List secondHandCards = await _deckApiService.ListHand( + deckId, + $"hand-{newHand.Id}" ); - List newHandCards = await _deckApiService.ListHand(deckId, $"hand-{newHand.Id}"); - await _roomSSEService.BroadcastEventAsync( + await secondHandCards.BroadcastAsync( + _roomSSEService, roomId, - RoomEventType.PlayerReveal, - new PlayerRevealEventData - { - PlayerHand = newHandCards, - PlayerScore = CalculateHandValue(newHandCards), - } + player.UserId, + newHand.HandNumber ); } /// /// Process a player's "surrender" action during their turn, forfeiting half their bet and ending their turn. /// - private async Task DoSurrenderAsync(BlackjackState state, Guid roomId, RoomPlayer player) + private async Task DoSurrenderAsync(BlackjackState _state, Guid roomId, RoomPlayer player) { // not allowed after splitting! // check if player only has one hand @@ -731,18 +663,16 @@ private async Task DoSurrenderAsync(BlackjackState state, Guid roomId, RoomPlaye } // refund half of player's bet (add to balance and update gamestate) - await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, hands[0].Bet / 2); + long refund = hands[0].Bet / 2; + await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, refund); - // Broadcast player action (surrender) - await _roomSSEService.BroadcastEventAsync( + // broadcast player action (surrender) + await _roomSSEService.BroadcastPlayerActionAsync( roomId, - RoomEventType.PlayerAction, - new PlayerActionEventData - { - PlayerId = player.Id, - Action = "surrender", - ActionDetails = new { RefundAmount = hands[0].Bet / 2 }, - } + player.Id, + hands[0].HandNumber, + "surrender", + amount: refund ); } @@ -776,13 +706,7 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) stage.PlayerIndex, stage.HandIndex + 1 ); - string updatedGameState = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameState } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); return; } catch (NotFoundException) @@ -806,13 +730,7 @@ await _roomSSEService.BroadcastEventAsync( nextPlayerIndex, 0 ); - string updatedGameState = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameState); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameState } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); return; } catch (NotFoundException) @@ -835,13 +753,7 @@ private async Task FinishRoundAsync(BlackjackState state, Guid roomId) { // transition to finish round stage state.CurrentStage = new BlackjackFinishRoundStage(); - string updatedGameStateFinish = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateFinish); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameStateFinish } - ); + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); Room room = await _roomRepository.GetByIdAsync(roomId) @@ -853,7 +765,7 @@ await _roomRepository.GetByIdAsync(roomId) ); // hit until dealer has at least 17 - int dealerValue = CalculateHandValue(dealerHand); + int dealerValue = dealerHand.CalculateHandValue(); while (dealerValue < 17) { dealerHand = await _deckApiService.DrawCards( @@ -861,15 +773,11 @@ await _roomRepository.GetByIdAsync(roomId) "dealer", 1 ); - dealerValue = CalculateHandValue(dealerHand); + dealerValue = dealerHand.CalculateHandValue(); } - // Broadcast final dealer hand (all cards face up) - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.DealerReveal, - new DealerRevealEventData { DealerHand = dealerHand, DealerScore = dealerValue } - ); + // broadcast final dealer hand (do not hide any cards) + await dealerHand.BroadcastAsync(_roomSSEService, roomId); // calculate winnings for each player hand List hands = await _handRepository.GetHandsByRoomIdAsync(roomId); @@ -880,7 +788,7 @@ await _roomSSEService.BroadcastEventAsync( $"hand-{hand.Id}" ); - switch (CompareHands(dealerHand, playerHand)) + switch (playerHand.CompareHand(dealerHand)) { case > 0: // player wins @@ -902,15 +810,12 @@ await _roomPlayerRepository.UpdatePlayerBalanceAsync( break; } - // Broadcast final player hand and score - await _roomSSEService.BroadcastEventAsync( + // broadcast final player hand + await playerHand.BroadcastAsync( + _roomSSEService, roomId, - RoomEventType.PlayerReveal, - new PlayerRevealEventData - { - PlayerHand = playerHand, - PlayerScore = CalculateHandValue(playerHand), - } + hand.RoomPlayerId, + hand.HandNumber ); } @@ -933,78 +838,6 @@ await _roomSSEService.BroadcastEventAsync( DateTimeOffset.UtcNow + _config.BettingTimeLimit, [] ); - string updatedGameStateNextRound = JsonSerializer.Serialize(state); - await _roomRepository.UpdateGameStateAsync(roomId, updatedGameStateNextRound); - await _roomSSEService.BroadcastEventAsync( - roomId, - RoomEventType.GameStateUpdate, - new GameStateUpdateEventData { GameStateJson = updatedGameStateNextRound } - ); - } - - static int CalculateHandValue(List hand, int target = 21) - { - int totalValue = 0; - int aceCount = 0; - - foreach (CardDTO card in hand) - { - if (card.IsFaceDown) - continue; // Do not count face-down cards for score calculation - - switch (card.Value.ToUpper()) - { - case "ACE": - aceCount++; - totalValue += 11; - break; - case "KING": - case "QUEEN": - case "JACK": - totalValue += 10; - break; - default: - //Number cards (2–10) → handled by: - if (int.TryParse(card.Value, out int val)) - totalValue += val; - break; - } - } - - while (totalValue > target && aceCount > 0) - { - totalValue -= 10; - aceCount--; - } - - return totalValue; - } - - /// - /// Compares dealer and player hands. - /// - /// - /// - /// Positive if player wins, negative if dealer wins, 0 if push - static int CompareHands(List dealerHand, List playerHand) - { - int dealerValue = CalculateHandValue(dealerHand); - int playerValue = CalculateHandValue(playerHand); - - // if player busts, dealer always wins - if (playerValue > 21) - return -1; - - // otherwise, if dealer busts, player wins - if (dealerValue > 21) - return 1; - - // blackjack beats a 21 - if (playerValue == 21 && dealerValue == 21) - { - return dealerHand.Count - playerHand.Count; - } - - return playerValue - dealerValue; + await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); } } diff --git a/Project.App/Project.Api/Services/DeckApiService.cs b/Project.App/Project.Api/Services/DeckApiService.cs index a04c9d2..651f3dc 100644 --- a/Project.App/Project.Api/Services/DeckApiService.cs +++ b/Project.App/Project.Api/Services/DeckApiService.cs @@ -6,9 +6,9 @@ namespace Project.Api.Services; -public class DeckApiService : IDeckApiService +public class DeckApiService(HttpClient client) : IDeckApiService { - private readonly HttpClient _httpClient; + private readonly HttpClient _httpClient = client; private readonly JsonSerializerOptions _jsonSerializerOptions = new() { @@ -18,11 +18,6 @@ public class DeckApiService : IDeckApiService private const string BASE_API_URL = "https://deckofcardsapi.com/api"; - public DeckApiService(HttpClient client) - { - _httpClient = client; - } - /// /// Create a new shuffled deck and return the deck ID. /// The deck will consist of specified number of standard decks shuffled together and two Jokers if enabled. @@ -77,7 +72,7 @@ public async Task CreateEmptyHand(string deckId, long handId) /// Player draws specified number of cards, count, from specified deck. /// Draws one card by default. /// - /// true if successful + /// the cards drawn public async Task> DrawCards(string deckId, long handId, int count = 1) { return await DrawCards(deckId, handId.ToString(), count); @@ -87,7 +82,7 @@ public async Task> DrawCards(string deckId, long handId, int count /// Player draws specified number of cards, count, from specified deck. /// Draws one card by default. /// - /// the resulting contents of the player’s hand + /// the list of cards drawn public async Task> DrawCards(string deckId, string handName, int count = 1) { // enforce non-negative count @@ -107,8 +102,8 @@ public async Task> DrawCards(string deckId, string handName, int c if (drawData?.Cards is null || drawData.Cards.Count == 0) { - // shouldn't happen, but if it does, return the current hand - return await ListHand(deckId, handName); + // shouldn't happen, but if it does, return empty list + return []; } // get card codes (as csv string) @@ -118,8 +113,8 @@ public async Task> DrawCards(string deckId, string handName, int c // add cards to the player’s hand await AddToHand(deckId, handName, cardsToAdd); - // return contents of hand - return await ListHand(deckId, handName); + // return only newly drawn cards + return drawData.Cards; } /// diff --git a/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs b/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs new file mode 100644 index 0000000..8bbd1b4 --- /dev/null +++ b/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Project.Api.DTOs; +using Project.Api.Models.Games; +using Project.Api.Repositories.Interface; +using Project.Api.Services.Interface; +using Project.Api.Utilities.Enums; + +namespace Project.Api.Utilities.Extensions; + +public static class BlackjackCardExtensions +{ + /// + /// Calculates the total value of a hand of blackjack cards. + /// + public static int CalculateHandValue(this List hand, int target = 21) + { + int totalValue = 0; + int aceCount = 0; + + foreach (CardDTO card in hand) + { + if (card.IsFaceDown) + continue; // Do not count face-down cards for score calculation + + switch (card.Value.ToUpper()) + { + case "ACE": + aceCount++; + totalValue += 11; + break; + case "KING": + case "QUEEN": + case "JACK": + totalValue += 10; + break; + default: + //Number cards (2–10) → handled by: + if (int.TryParse(card.Value, out int val)) + totalValue += val; + break; + } + } + + while (totalValue > target && aceCount > 0) + { + totalValue -= 10; + aceCount--; + } + + return totalValue; + } + + /// + /// Compares dealer and player hands. + /// + /// Positive if player wins, negative if dealer wins, 0 if push + public static int CompareHand(this List playerHand, List dealerHand) + { + int dealerValue = CalculateHandValue(dealerHand); + int playerValue = CalculateHandValue(playerHand); + + // if player busts, dealer always wins + if (playerValue > 21) + return -1; + + // otherwise, if dealer busts, player wins + if (dealerValue > 21) + return 1; + + // blackjack beats a 21 + if (playerValue == 21 && dealerValue == 21) + { + return dealerHand.Count - playerHand.Count; + } + + return playerValue - dealerValue; + } + + /// + /// Saves the current game state and broadcasts an update to connected clients, if the repository and/or service are provided. + /// + public static async Task SaveStateAndBroadcastAsync( + this BlackjackState state, + Guid roomId, + IRoomRepository? roomRepository = null, + IRoomSSEService? roomSSEService = null + ) + { + if (roomRepository is not null) + { + // save state + string updatedGameState = JsonSerializer.Serialize(state); + await roomRepository.UpdateGameStateAsync(roomId, updatedGameState); + } + + if (roomSSEService is not null) + { + // broadcast state update + await roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.GameStateUpdate, + new GameStateUpdateEventData { CurrentStage = state.CurrentStage } + ); + } + } +} diff --git a/Project.App/Project.Api/Utilities/Extensions/RoomSSEExtensions.cs b/Project.App/Project.Api/Utilities/Extensions/RoomSSEExtensions.cs new file mode 100644 index 0000000..15dcbae --- /dev/null +++ b/Project.App/Project.Api/Utilities/Extensions/RoomSSEExtensions.cs @@ -0,0 +1,95 @@ +using Project.Api.DTOs; +using Project.Api.Models.Games; +using Project.Api.Services.Interface; +using Project.Api.Utilities.Enums; + +namespace Project.Api.Utilities.Extensions; + +public static class RoomSSEExtensions +{ + /// + /// Broadcasts a hand of cards to the room using the given SSE service, optionally hiding certain cards. + /// + public static async Task BroadcastAsync( + this List hand, + IRoomSSEService sseService, + Guid roomId, + Guid? playerId = null, + int handIndex = 0, + params int[] hiddenIndices + ) + { + List handCopy = + [ + .. hand.Select( + (card, index) => + hiddenIndices.Contains(index) + ? new CardDTO { IsFaceDown = true } // if face down, hide all info + : card + ), + ]; + + if (playerId.HasValue) + { + await sseService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerReveal, + new PlayerRevealEventData + { + PlayerId = + playerId + ?? throw new InternalServerException( + $"Player ID {playerId} is somehow both null and has a value. How did we get here?" + ), + HandIndex = handIndex, + PlayerHand = handCopy, + PlayerScore = hand.CalculateHandValue(), + } + ); + } + else + { + // broadcast as dealer hand if no player ID given + await sseService.BroadcastEventAsync( + roomId, + RoomEventType.DealerReveal, + new DealerRevealEventData + { + DealerHand = handCopy, + DealerScore = hand.CalculateHandValue(), + } + ); + } + } + + /// + /// Broadcasts a player action to the room using the given SSE service. + /// + public static async Task BroadcastPlayerActionAsync( + this IRoomSSEService roomSSEService, + Guid roomId, + Guid userId, + int handNumber, + string action, + long? amount = null, + List? cards = null, + Guid? targetPlayerId = null, + bool? success = null + ) + { + await roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerAction, + new PlayerActionEventData + { + PlayerId = userId, + HandIndex = handNumber, + Action = action, + Amount = amount, + Cards = cards, + TargetPlayerId = targetPlayerId, + Success = success, + } + ); + } +} diff --git a/Project.App/Project.Test/Services/DeckApiServiceTest.cs b/Project.App/Project.Test/Services/DeckApiServiceTest.cs index 04e2b7c..a71e63e 100644 --- a/Project.App/Project.Test/Services/DeckApiServiceTest.cs +++ b/Project.App/Project.Test/Services/DeckApiServiceTest.cs @@ -75,17 +75,6 @@ public async Task PlayerDraw_ShouldReturnCardList() ""success"": true }"; - var listResponse = - @"{ - ""piles"": { - ""42"": { - ""cards"": [ - { ""code"": ""AS"", ""image"": ""url1"", ""value"": ""ACE"", ""suit"": ""SPADES"" } - ] - } - } - }"; - var handlerMock = new Mock(); var responses = new Queue( [ @@ -97,15 +86,6 @@ public async Task PlayerDraw_ShouldReturnCardList() { Content = new StringContent(addResponse), }, - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(listResponse), - }, - // service calls list twice (listPile then listHand), return same payload again - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(listResponse), - }, ] ); @@ -122,7 +102,7 @@ public async Task PlayerDraw_ShouldReturnCardList() var service = new DeckApiService(client); // Act - var result = await service.DrawCards("deck123", 42); + var result = await service.DrawCards("deck123", "42"); // Changed handId to handName string // Assert Assert.NotNull(result); @@ -133,6 +113,56 @@ public async Task PlayerDraw_ShouldReturnCardList() Assert.Equal("url1", result[0].Image); } + [Fact] + public async Task DrawCards_ShouldReturnEmptyList_WhenNoCardsAreDrawn() + { + // Arrange + var drawResponse = + @"{ + ""cards"": [] + }"; // Empty cards array + + var handlerMock = new Mock(); + var responses = new Queue( + [ + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(drawResponse), + }, + ] + ); + + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(responses.Dequeue); + + var client = new HttpClient(handlerMock.Object); + var service = new DeckApiService(client); + + // Act + var result = await service.DrawCards("deck123", "playerHand", 1); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); // Expect an empty list + // Verify that AddToHand was NOT called since no cards were drawn + handlerMock + .Protected() + .Verify( + "SendAsync", + Times.Never(), + ItExpr.Is(req => + req.RequestUri!.ToString().Contains("/add/") + ), + ItExpr.IsAny() + ); + } + [Fact] public async Task ReturnAllCardsToDeck_ShouldReturnTrue() { From 3422fcd3a2b6ba0ff2906318053129ae22a04766 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 11:59:32 -0400 Subject: [PATCH 09/18] Extract SSE connection helper method to helper class --- .../Helpers/IntegrationTestBase.cs | 22 ++++++ Project.App/Project.Test/Helpers/SSEHelper.cs | 41 ++++++++++ .../Integration/RoomSSEIntegrationTests.cs | 77 ++++--------------- 3 files changed, 79 insertions(+), 61 deletions(-) create mode 100644 Project.App/Project.Test/Helpers/SSEHelper.cs diff --git a/Project.App/Project.Test/Helpers/IntegrationTestBase.cs b/Project.App/Project.Test/Helpers/IntegrationTestBase.cs index f1ee511..29c0779 100644 --- a/Project.App/Project.Test/Helpers/IntegrationTestBase.cs +++ b/Project.App/Project.Test/Helpers/IntegrationTestBase.cs @@ -7,8 +7,10 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Project.Api; using Project.Api.Data; +using Project.Api.Services.Interface; using Project.Api.Utilities.Enums; namespace Project.Test.Helpers; @@ -71,4 +73,24 @@ protected HttpClient CreateTestClient( }) .CreateClient(); } + + /// + /// Helper to create an HttpClient configured to use a specific IRoomSSEService instance. + /// + protected HttpClient CreateSSEClientWithMocks( + IRoomSSEService sseService, + Action? testServicesConfiguration = null + ) + { + return CreateTestClient(services => + { + services.RemoveAll(); + services.AddSingleton(sseService); + + services.AddScoped(_ => Mock.Of()); + + // mock specific services for the test as necessary + testServicesConfiguration?.Invoke(services); + }); + } } diff --git a/Project.App/Project.Test/Helpers/SSEHelper.cs b/Project.App/Project.Test/Helpers/SSEHelper.cs new file mode 100644 index 0000000..cf9370a --- /dev/null +++ b/Project.App/Project.Test/Helpers/SSEHelper.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace Project.Test.Helpers; + +public static class SSEHelper +{ + /// + /// Helper to open an SSE connection and return the StreamReader. + /// + public static async Task<(StreamReader reader, CancellationTokenSource cts)> OpenSseConnection( + this HttpClient client, + Guid roomId + ) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/room/{roomId}/events"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream") + ); + + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType); + + var stream = await response.Content.ReadAsStreamAsync(); + var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + + // Wait a moment so the connection is established and the “: connected” line is sent + await Task.Delay(100); + string firstLine = await reader.ReadLineAsync() ?? "No line received!"; + Assert.Equal(": connected", firstLine); + + // Create a CancellationTokenSource to simulate client disconnection later if needed + var cts = new CancellationTokenSource(); + // This is a bit tricky for integration tests, as the actual cancellation token + // is tied to the HttpContext.RequestAborted. We can't directly cancel it from here + // without disposing the client, which closes the connection. + // For explicit disconnection testing, we'll rely on client.Dispose(). + return (reader, cts); + } +} diff --git a/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs b/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs index cc269d9..3107863 100644 --- a/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs +++ b/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs @@ -7,7 +7,7 @@ using Moq; using Project.Api; using Project.Api.DTOs; -using Project.Api.Models.Games; // Add this using directive for MessageEventData +using Project.Api.Models.Games; using Project.Api.Services; using Project.Api.Services.Interface; using Project.Test.Helpers; @@ -17,64 +17,14 @@ namespace Project.Test.Integration; public class RoomSseIntegrationTests(WebApplicationFactory factory) : IntegrationTestBase(factory) { - /// - /// Helper to create an HttpClient configured to use a specific IRoomSSEService instance. - /// - private HttpClient CreateClientWithMocks(IRoomSSEService sseService) - { - return CreateTestClient(services => - { - services.RemoveAll(); - services.AddSingleton(sseService); - - services.AddScoped(_ => Mock.Of()); - }); - } - - /// - /// Helper to open an SSE connection and return the StreamReader. - /// - private async Task<( - HttpClient client, - StreamReader reader, - CancellationTokenSource cts - )> OpenSseConnection(Guid roomId, IRoomSSEService sseService) - { - var client = CreateClientWithMocks(sseService); - var request = new HttpRequestMessage(HttpMethod.Get, $"/api/room/{roomId}/events"); - request.Headers.Accept.Clear(); - request.Headers.Accept.Add( - new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream") - ); - - var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType); - - var stream = await response.Content.ReadAsStreamAsync(); - var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); - - // Wait a moment so the connection is established and the “: connected” line is sent - await Task.Delay(100); - string firstLine = await reader.ReadLineAsync() ?? "No line received!"; - Assert.Equal(": connected", firstLine); - - // Create a CancellationTokenSource to simulate client disconnection later if needed - var cts = new CancellationTokenSource(); - // This is a bit tricky for integration tests, as the actual cancellation token - // is tied to the HttpContext.RequestAborted. We can't directly cancel it from here - // without disposing the client, which closes the connection. - // For explicit disconnection testing, we'll rely on client.Dispose(). - return (client, reader, cts); - } - [Fact] public async Task GetRoomEvents_StreamReceivesBroadcastEvents_SingleClient() { // Arrange var sseService = new RoomSSEService(); var roomId = Guid.NewGuid(); - var (client, reader, cts) = await OpenSseConnection(roomId, sseService); + var client = CreateSSEClientWithMocks(sseService); + var (reader, cts) = await client.OpenSseConnection(roomId); var messageContent = "Hello everyone!"; var message = new MessageDTO(messageContent); @@ -126,8 +76,10 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_MultipleClientsInS var roomId = Guid.NewGuid(); // Open two connections to the same room - var (client1, reader1, cts1) = await OpenSseConnection(roomId, sseService); - var (client2, reader2, cts2) = await OpenSseConnection(roomId, sseService); + var client1 = CreateSSEClientWithMocks(sseService); + var (reader1, cts1) = await client1.OpenSseConnection(roomId); + var client2 = CreateSSEClientWithMocks(sseService); + var (reader2, cts2) = await client2.OpenSseConnection(roomId); var messageContent = "Group chat!"; var message = new MessageDTO(messageContent); @@ -184,8 +136,10 @@ public async Task GetRoomEvents_StreamReceivesBroadcastEvents_ClientsInDifferent var roomId2 = Guid.NewGuid(); // Open connections to different rooms - var (client1, reader1, cts1) = await OpenSseConnection(roomId1, sseService); - var (client2, reader2, cts2) = await OpenSseConnection(roomId2, sseService); + var client1 = CreateSSEClientWithMocks(sseService); + var (reader1, cts1) = await client1.OpenSseConnection(roomId1); + var client2 = CreateSSEClientWithMocks(sseService); + var (reader2, cts2) = await client2.OpenSseConnection(roomId2); var messageContent = "Only for room 1!"; var message = new MessageDTO(messageContent); @@ -242,7 +196,7 @@ public async Task GetRoomEvents_WithInvalidAcceptHeader_ReturnsBadRequest() { // Arrange var sseService = new RoomSSEService(); - var client = CreateClientWithMocks(sseService); + var client = CreateSSEClientWithMocks(sseService); var roomId = Guid.NewGuid(); var request = new HttpRequestMessage(HttpMethod.Get, $"/api/room/{roomId}/events"); @@ -275,7 +229,7 @@ public async Task BroadcastMessage_WithInvalidContent_ReturnsBadRequest(string? { // Arrange var sseService = new RoomSSEService(); - var client = CreateClientWithMocks(sseService); + var client = CreateSSEClientWithMocks(sseService); var roomId = Guid.NewGuid(); var message = new MessageDTO(content!); @@ -299,7 +253,8 @@ public async Task GetRoomEvents_ClientDisconnectsGracefully() var roomId = Guid.NewGuid(); // Open a connection - var (client, reader, cts) = await OpenSseConnection(roomId, sseService); + var client = CreateSSEClientWithMocks(sseService); + var (reader, cts) = await client.OpenSseConnection(roomId); // Act: Simulate client disconnection by disposing the HttpClient client.Dispose(); @@ -310,7 +265,7 @@ public async Task GetRoomEvents_ClientDisconnectsGracefully() // Act: Try to broadcast an event to the room var messageContent = "Should not be received by disconnected client."; var message = new MessageDTO(messageContent); - var postResponse = await CreateClientWithMocks(sseService) + var postResponse = await CreateSSEClientWithMocks(sseService) .PostAsJsonAsync($"/api/room/{roomId}/chat", message); postResponse.EnsureSuccessStatusCode(); From 65609503ec68249c2f80c4d218e83efe18f3a32d Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 12:46:30 -0400 Subject: [PATCH 10/18] Add check if all active players have bet --- .../Project.Api/Services/BlackjackService.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index f575c07..9574528 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -169,14 +169,26 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) case BetAction betAction: await ProcessBetAsync(state, roomId, player, betAction.Amount); - // if not past deadline, do not move to next stage + // if past deadline, move to next stage if (DateTime.UtcNow < ((BlackjackBettingStage)state.CurrentStage).Deadline) { + await StartRoundAsync(state, roomId); break; } - // time is up, start the round - await StartRoundAsync(state, roomId); + // if all active players have bet, start the round + List activePlayers = + [ + .. await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId), + ]; + Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; + if (activePlayers.All(p => bets.ContainsKey(p.Id)) && bets.Count > 0) + { + await StartRoundAsync(state, roomId); + break; + } + + // otherwise, do nothing break; case HitAction: bool busted = await DoHitAsync(state, roomId, player); From 07a4993de248771205de0105b105433d522f9b67 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 13:19:01 -0400 Subject: [PATCH 11/18] Fix blackjack service --- .../Project.Api/Services/BlackjackService.cs | 19 ++++++++----- .../Services/BlackjackServiceTest.cs | 27 ++++++++++++++++--- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 9574528..91a4022 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -169,20 +169,27 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) case BetAction betAction: await ProcessBetAsync(state, roomId, player, betAction.Amount); + BlackjackBettingStage bettingStage = (BlackjackBettingStage)state.CurrentStage; + // if past deadline, move to next stage - if (DateTime.UtcNow < ((BlackjackBettingStage)state.CurrentStage).Deadline) + if (DateTime.UtcNow > bettingStage.Deadline) { await StartRoundAsync(state, roomId); break; } - // if all active players have bet, start the round - List activePlayers = + // if all players that are active or away have bet, start the round + List players = [ - .. await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId), + .. await _roomPlayerRepository.GetByRoomIdAsync(roomId), ]; - Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; - if (activePlayers.All(p => bets.ContainsKey(p.Id)) && bets.Count > 0) + Dictionary bets = bettingStage.Bets; + if ( + players + .Where(p => p.Status == Status.Active || p.Status == Status.Away) + .All(p => bets.ContainsKey(p.Id)) + && bets.Count > 0 + ) { await StartRoundAsync(state, roomId); break; diff --git a/Project.App/Project.Test/Services/BlackjackServiceTest.cs b/Project.App/Project.Test/Services/BlackjackServiceTest.cs index 62b8b68..8c794f1 100644 --- a/Project.App/Project.Test/Services/BlackjackServiceTest.cs +++ b/Project.App/Project.Test/Services/BlackjackServiceTest.cs @@ -65,17 +65,36 @@ public async Task PerformActionAsync_BetAction_Success_BeforeDeadline() Status = Status.Away, }; - var bettingStage = new BlackjackBettingStage( - DateTimeOffset.UtcNow.AddMinutes(1), - new Dictionary() - ); + // another player who has not bet yet + // ensures round does not start prematurely + var player2 = new RoomPlayer + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + RoomId = roomId, + Balance = 1000, + Status = Status.Away, + }; + + var bettingStage = new BlackjackBettingStage(DateTimeOffset.UtcNow.AddMinutes(1), []); var gameState = new BlackjackState { CurrentStage = bettingStage }; var gameStateString = JsonSerializer.Serialize(gameState); + var room = new Room + { + Id = roomId, + GameMode = GameModes.Blackjack, + GameState = gameStateString, + DeckId = "test_deck", + }; _roomRepositoryMock.Setup(r => r.GetGameStateAsync(roomId)).ReturnsAsync(gameStateString); + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); _roomPlayerRepositoryMock .Setup(r => r.GetByRoomIdAndUserIdAsync(roomId, playerId)) .ReturnsAsync(player); + _roomPlayerRepositoryMock + .Setup(r => r.GetByRoomIdAsync(roomId)) + .ReturnsAsync([player, player2]); // Act await _blackjackService.PerformActionAsync( From 65ca9c60c22d2982e2365709dc472ddeb565f24d Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 13:24:16 -0400 Subject: [PATCH 12/18] Remove unused imports --- .../Project.Test/Integration/RoomSSEIntegrationTests.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs b/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs index 3107863..a7f9505 100644 --- a/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs +++ b/Project.App/Project.Test/Integration/RoomSSEIntegrationTests.cs @@ -1,15 +1,10 @@ using System.Net.Http.Json; -using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Moq; using Project.Api; using Project.Api.DTOs; using Project.Api.Models.Games; using Project.Api.Services; -using Project.Api.Services.Interface; using Project.Test.Helpers; namespace Project.Test.Integration; From b00c9f46d536c542410d186db4c8e2a638c831a1 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 21:19:17 -0400 Subject: [PATCH 13/18] Finish integration test and apply fixes --- .../Project.Api/Controllers/RoomController.cs | 2 +- Project.App/Project.Api/DTOs/RoomDTOs.cs | 10 +- Project.App/Project.Api/Models/Room.cs | 8 +- Project.App/Project.Api/Program.cs | 3 +- .../Project.Api/Services/BlackjackService.cs | 169 ++++-- .../Services/Interface/IGameService.cs | 6 +- .../Project.Api/Services/RoomService.cs | 141 ++--- .../Extensions/BlackjackCardExtensions.cs | 10 +- .../Controllers/RoomControllerTest.cs | 65 +- .../Helpers/IntegrationTestBase.cs | 61 +- .../Project.Test/Helpers/MockDeckAPIHelper.cs | 222 +++++++ .../Integration/BlackjackIntegrationTests.cs | 563 ++++++++++++++++++ .../Services/BlackjackServiceTest.cs | 6 + .../Project.Test/Services/RoomServiceTest.cs | 407 +++++++------ 14 files changed, 1318 insertions(+), 355 deletions(-) create mode 100644 Project.App/Project.Test/Helpers/MockDeckAPIHelper.cs create mode 100644 Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs diff --git a/Project.App/Project.Api/Controllers/RoomController.cs b/Project.App/Project.Api/Controllers/RoomController.cs index 174f71a..bf0503f 100644 --- a/Project.App/Project.Api/Controllers/RoomController.cs +++ b/Project.App/Project.Api/Controllers/RoomController.cs @@ -95,7 +95,7 @@ public async Task> CreateRoom([FromBody] CreateRoomDTO dto if (!ModelState.IsValid) return BadRequest(ModelState); - var room = await _roomService.CreateRoomAsync(dto); + RoomDTO room = await _roomService.CreateRoomAsync(dto); return CreatedAtAction(nameof(GetRoomById), new { id = room.Id }, room); } diff --git a/Project.App/Project.Api/DTOs/RoomDTOs.cs b/Project.App/Project.Api/DTOs/RoomDTOs.cs index 5465a3a..ad75dae 100644 --- a/Project.App/Project.Api/DTOs/RoomDTOs.cs +++ b/Project.App/Project.Api/DTOs/RoomDTOs.cs @@ -12,21 +12,19 @@ public class RoomDTO public int MaxPlayers { get; set; } public int MinPlayers { get; set; } public string DeckId { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } public bool IsActive { get; set; } } public class CreateRoomDTO { public Guid HostId { get; set; } - public bool IsPublic { get; set; } + public bool IsPublic { get; set; } = true; public string GameMode { get; set; } = string.Empty; - public string GameState { get; set; } = string.Empty; public string GameConfig { get; set; } = string.Empty; public string? Description { get; set; } - public int MaxPlayers { get; set; } - public int MinPlayers { get; set; } - public string DeckId { get; set; } = string.Empty; + public int MaxPlayers { get; set; } = 10; + public int MinPlayers { get; set; } = 1; } public class UpdateRoomDTO : CreateRoomDTO diff --git a/Project.App/Project.Api/Models/Room.cs b/Project.App/Project.Api/Models/Room.cs index 035a65e..a5843e6 100644 --- a/Project.App/Project.Api/Models/Room.cs +++ b/Project.App/Project.Api/Models/Room.cs @@ -2,8 +2,6 @@ using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Project.Api.Repositories.Interface; -using Project.Api.Services.Interface; namespace Project.Api.Models; @@ -19,11 +17,11 @@ public class Room public bool IsActive { get; set; } - public DateTime CreatedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } - public DateTime? StartedAt { get; set; } + public DateTimeOffset? StartedAt { get; set; } - public DateTime? EndedAt { get; set; } + public DateTimeOffset? EndedAt { get; set; } [MaxLength(50)] public required string GameMode { get; set; } diff --git a/Project.App/Project.Api/Program.cs b/Project.App/Project.Api/Program.cs index 15c77aa..66d4ac9 100644 --- a/Project.App/Project.Api/Program.cs +++ b/Project.App/Project.Api/Program.cs @@ -33,7 +33,8 @@ public static void Main(string[] args) builder.Host.UseSerilog(); // use extension methods to configure services - builder.Services.AddDatabase(builder.Configuration); + if (!builder.Environment.IsEnvironment("Testing")) + builder.Services.AddDatabase(builder.Configuration); builder.Services.AddApplicationServices(); builder.Services.AddCorsPolicy(); builder.Services.AddAuth(builder.Configuration, builder.Environment); diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 91a4022..a9159f9 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -79,17 +79,22 @@ ILogger logger ) : IBlackjackService { private readonly IRoomRepository _roomRepository = roomRepository; - private readonly IRoomPlayerRepository _roomPlayerRepository = roomPlayerRepository; + private readonly IRoomPlayerRepository _roomPlayerRepository = roomPlayerRepository; // TODO: use transactions for long-running tasks private readonly IHandRepository _handRepository = handRepository; private readonly IDeckApiService _deckApiService = deckApiService; private readonly IRoomSSEService _roomSSEService = roomSSEService; private readonly ILogger _logger = logger; // TODO: add more logging - private BlackjackConfig _config = new(); - public BlackjackConfig Config + public async Task GetConfigAsync(Guid gameId) { - get => _config; - set => _config = value; + string configString = await _roomRepository.GetGameConfigAsync(gameId); + return JsonSerializer.Deserialize(configString)!; + } + + public async Task SetConfigAsync(Guid gameId, BlackjackConfig config) + { + string configString = JsonSerializer.Serialize(config); + await _roomRepository.UpdateGameConfigAsync(gameId, configString); } public async Task GetGameStateAsync(Guid roomId) @@ -112,27 +117,41 @@ public static bool IsActionValid(string action, BlackjackStage stage) => _ => false, }; - public async Task SetupGameAsync(Guid roomId) + public async Task StartGameAsync(Guid roomId, BlackjackConfig config) { // create initial game state BlackjackState initialState = new() { CurrentStage = new BlackjackBettingStage( - DateTimeOffset.UtcNow + _config.BettingTimeLimit, + DateTimeOffset.UtcNow + config.BettingTimeLimit, [] ), }; + // initialize player balances + await _roomPlayerRepository.UpdatePlayersInRoomAsync( + roomId, + player => + { + player.Balance = config.StartingBalance; + player.Status = Status.Away; // Players start as Away, become Active when they bet + } + ); + // create new deck string deckId = await _deckApiService.CreateDeck(); - // save to room + // update room Room room = await _roomRepository.GetByIdAsync(roomId) ?? throw new NotFoundException($"Room {roomId} not found."); + room.GameConfig = JsonSerializer.Serialize(config); + room.StartedAt = DateTimeOffset.UtcNow; room.DeckId = deckId; room.GameState = JsonSerializer.Serialize(initialState); + room.IsActive = true; + room.Round = 1; await _roomRepository.UpdateAsync(room); @@ -163,6 +182,9 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) BlackjackActionDTO actionDTO = data.ToBlackjackAction(action); + // get config + BlackjackConfig config = await GetConfigAsync(roomId); + // do the action :) switch (actionDTO) { @@ -172,7 +194,7 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) BlackjackBettingStage bettingStage = (BlackjackBettingStage)state.CurrentStage; // if past deadline, move to next stage - if (DateTime.UtcNow > bettingStage.Deadline) + if (DateTimeOffset.UtcNow > bettingStage.Deadline) { await StartRoundAsync(state, roomId); break; @@ -198,7 +220,7 @@ .. await _roomPlayerRepository.GetByRoomIdAsync(roomId), // otherwise, do nothing break; case HitAction: - bool busted = await DoHitAsync(state, roomId, player); + (state, bool busted) = await DoHitAsync(state, roomId, player); if (busted) { @@ -208,7 +230,7 @@ .. await _roomPlayerRepository.GetByRoomIdAsync(roomId), { // stay on the same player's turn, but reset the deadline ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( - _config.TurnTimeLimit + config.TurnTimeLimit ); await state.SaveStateAndBroadcastAsync( roomId, @@ -221,6 +243,21 @@ await state.SaveStateAndBroadcastAsync( case StandAction: // do nothing :) + // broadcast player action (stand) + BlackjackPlayerActionStage stage = (BlackjackPlayerActionStage)state.CurrentStage; + Hand hand = await _handRepository.GetHandByRoomOrderAsync( + roomId, + stage.PlayerIndex, + stage.HandIndex + ); + + await _roomSSEService.BroadcastPlayerActionAsync( + roomId, + player.UserId, + hand.HandNumber, + "stand" + ); + // next player or next stage await NextHandOrFinishRoundAsync(state, roomId); break; @@ -236,7 +273,7 @@ await state.SaveStateAndBroadcastAsync( // stay on the same player's turn, but reset the deadline ((BlackjackPlayerActionStage)state.CurrentStage).ResetDeadline( - _config.TurnTimeLimit + config.TurnTimeLimit ); await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); break; @@ -265,7 +302,7 @@ .. await _roomPlayerRepository.GetActivePlayersInRoomAsync(roomId), break; case BlackjackPlayerActionStage actionStage: // check if deadline has passed - if (DateTime.UtcNow < actionStage.Deadline) + if (DateTimeOffset.UtcNow < actionStage.Deadline) { // deadline not passed, broadcast failed player action await _roomSSEService.BroadcastPlayerActionAsync( @@ -308,7 +345,7 @@ await _roomSSEService.BroadcastPlayerActionAsync( } // act as if the player stood - await NextHandOrFinishRoundAsync(state, roomId); + await NextHandOrFinishRoundAsync(state, roomId, config); break; default: throw new BadRequestException("Nothing to hurry in the current stage."); @@ -348,7 +385,13 @@ long bet await _roomPlayerRepository.UpdateAsync(player); // broadcast player action (bet) - await _roomSSEService.BroadcastPlayerActionAsync(roomId, player.Id, 0, "bet", amount: bet); + await _roomSSEService.BroadcastPlayerActionAsync( + roomId, + player.UserId, + 0, + "bet", + amount: bet + ); await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); } @@ -357,8 +400,14 @@ long bet /// Start the round after betting is complete. /// Deduct bets from player balances and move to dealing stage. /// - private async Task StartRoundAsync(BlackjackState state, Guid roomId) + private async Task StartRoundAsync( + BlackjackState state, + Guid roomId, + BlackjackConfig? config = null + ) { + config ??= await GetConfigAsync(roomId); + // get bets Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; @@ -441,7 +490,7 @@ await playerHandCards.BroadcastAsync( // move to player action stage state.CurrentStage = new BlackjackPlayerActionStage( - DateTimeOffset.UtcNow + _config.TurnTimeLimit, + DateTimeOffset.UtcNow + config.TurnTimeLimit, 0, 0 ); @@ -491,7 +540,11 @@ await _roomRepository.GetByIdAsync(roomId) /// Process a player's "hit" action during their turn. /// /// true if player busted - private async Task DoHitAsync(BlackjackState state, Guid roomId, RoomPlayer player) + private async Task<(BlackjackState, bool)> DoHitAsync( + BlackjackState state, + Guid roomId, + RoomPlayer player + ) { (_, Hand hand, string deckId) = await InitializePlayerActionAsync(state, roomId, player); @@ -502,7 +555,7 @@ private async Task DoHitAsync(BlackjackState state, Guid roomId, RoomPlaye // broadcast player action (hit) await _roomSSEService.BroadcastPlayerActionAsync( roomId, - player.Id, + player.UserId, hand.HandNumber, "hit", cards: drawnCards @@ -516,8 +569,8 @@ await playerHandCards.BroadcastAsync( hand.HandNumber ); - // return if player busted - return playerHandCards.CalculateHandValue() > 21; + // return state and if player busted + return (state, playerHandCards.CalculateHandValue() > 21); } /// @@ -558,7 +611,7 @@ private async Task DoDoubleAsync(BlackjackState state, Guid roomId, RoomPlayer p // broadcast player action (double) await _roomSSEService.BroadcastPlayerActionAsync( roomId, - player.Id, + player.UserId, hand.HandNumber, "double", cards: drawnCard, @@ -642,7 +695,7 @@ await _handRepository.CreateHandAsync( // broadcast player action (split) await _roomSSEService.BroadcastPlayerActionAsync( roomId, - player.Id, + player.UserId, hand.HandNumber, "split", amount: amount @@ -671,25 +724,29 @@ await secondHandCards.BroadcastAsync( /// /// Process a player's "surrender" action during their turn, forfeiting half their bet and ending their turn. /// - private async Task DoSurrenderAsync(BlackjackState _state, Guid roomId, RoomPlayer player) + private async Task DoSurrenderAsync(BlackjackState state, Guid roomId, RoomPlayer player) { - // not allowed after splitting! - // check if player only has one hand - List hands = await _handRepository.GetHandsByUserIdAsync(roomId, player.Id); - if (hands.Count > 1) + (BlackjackPlayerActionStage stage, Hand hand, string deckId) = + await InitializePlayerActionAsync(state, roomId, player); + + List handCards = await _deckApiService.ListHand(deckId, $"hand-{hand.Id}"); + + // can only surrender on first action + // check if player only has two cards in hand + if (stage.HandIndex != 0 || handCards.Count > 2) { - throw new BadRequestException("Surrender is not allowed after splitting."); + throw new BadRequestException("Surrender is only allowed on your first action."); } - // refund half of player's bet (add to balance and update gamestate) - long refund = hands[0].Bet / 2; + // refund half of player's bet + long refund = hand.Bet / 2; await _roomPlayerRepository.UpdatePlayerBalanceAsync(player.Id, refund); // broadcast player action (surrender) await _roomSSEService.BroadcastPlayerActionAsync( roomId, - player.Id, - hands[0].HandNumber, + player.UserId, + hand.HandNumber, "surrender", amount: refund ); @@ -698,8 +755,14 @@ await _roomSSEService.BroadcastPlayerActionAsync( /// /// Move to the next player/hand turn, or if no players/hands are left, move to next stage (dealer turn). /// - private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) + private async Task NextHandOrFinishRoundAsync( + BlackjackState state, + Guid roomId, + BlackjackConfig? config = null + ) { + config ??= await GetConfigAsync(roomId); + if (state.CurrentStage is not BlackjackPlayerActionStage) { throw new InvalidOperationException( @@ -721,7 +784,7 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) // if so, move to next hand state.CurrentStage = new BlackjackPlayerActionStage( - DateTimeOffset.UtcNow + _config.TurnTimeLimit, + DateTimeOffset.UtcNow + config.TurnTimeLimit, stage.PlayerIndex, stage.HandIndex + 1 ); @@ -745,7 +808,7 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) // if so, move to next player state.CurrentStage = new BlackjackPlayerActionStage( - DateTimeOffset.UtcNow + _config.TurnTimeLimit, + DateTimeOffset.UtcNow + config.TurnTimeLimit, nextPlayerIndex, 0 ); @@ -758,7 +821,7 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) } // all players have finished, move to dealer turn and finish round - await FinishRoundAsync(state, roomId); + await FinishRoundAsync(state, roomId, config); } // After the players have finished playing, the dealer's hand is resolved by drawing cards until @@ -768,8 +831,14 @@ private async Task NextHandOrFinishRoundAsync(BlackjackState state, Guid roomId) // If the dealer does not bust, each remaining bet wins if its hand is higher than the dealer's and // loses if it is lower. In the case of a tie ("push" or "standoff"), bets are returned without adjustment. // A blackjack beats any hand that is not a blackjack, even one with a value of 21. - private async Task FinishRoundAsync(BlackjackState state, Guid roomId) + private async Task FinishRoundAsync( + BlackjackState state, + Guid roomId, + BlackjackConfig? config = null + ) { + config ??= await GetConfigAsync(roomId); + // transition to finish round stage state.CurrentStage = new BlackjackFinishRoundStage(); await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); @@ -785,14 +854,30 @@ await _roomRepository.GetByIdAsync(roomId) // hit until dealer has at least 17 int dealerValue = dealerHand.CalculateHandValue(); + _logger.LogInformation( + "Room {roomId} - Dealer hand: {dealerHand} ({dealerValue})", + roomId, + string.Join(", ", dealerHand.Select(c => c.Code)), + dealerValue + ); while (dealerValue < 17) { - dealerHand = await _deckApiService.DrawCards( + await _deckApiService.DrawCards( room.DeckId ?? throw new InternalServerException($"Room {roomId} has no deck ID."), "dealer", 1 ); + dealerHand = await _deckApiService.ListHand( + room.DeckId ?? throw new InternalServerException($"Room {roomId} has no deck ID."), + "dealer" + ); dealerValue = dealerHand.CalculateHandValue(); + _logger.LogInformation( + "Room {roomId} - Dealer hit: {dealerHand} ({dealerValue})", + roomId, + string.Join(", ", dealerHand), + dealerValue + ); } // broadcast final dealer hand (do not hide any cards) @@ -854,9 +939,13 @@ await playerHand.BroadcastAsync( // initialize next betting stage state.CurrentStage = new BlackjackBettingStage( - DateTimeOffset.UtcNow + _config.BettingTimeLimit, + DateTimeOffset.UtcNow + config.BettingTimeLimit, [] ); await state.SaveStateAndBroadcastAsync(roomId, _roomRepository, _roomSSEService); + + // increment round + room.Round += 1; + await _roomRepository.UpdateAsync(room); } } diff --git a/Project.App/Project.Api/Services/Interface/IGameService.cs b/Project.App/Project.Api/Services/Interface/IGameService.cs index 0abbb31..cd6939d 100644 --- a/Project.App/Project.Api/Services/Interface/IGameService.cs +++ b/Project.App/Project.Api/Services/Interface/IGameService.cs @@ -12,14 +12,16 @@ public interface IGameService where TState : IGameState where TConfig : GameConfig { - TConfig Config { get; set; } + Task GetConfigAsync(Guid gameId); + + Task SetConfigAsync(Guid gameId, TConfig config); Task GetGameStateAsync(Guid gameId); /// /// Sets up the game with initial state. /// - Task SetupGameAsync(Guid gameId); + Task StartGameAsync(Guid gameId, TConfig config); /// /// Performs a user action on the game, if valid, then updates the game state. diff --git a/Project.App/Project.Api/Services/RoomService.cs b/Project.App/Project.Api/Services/RoomService.cs index b08e45b..30b6867 100644 --- a/Project.App/Project.Api/Services/RoomService.cs +++ b/Project.App/Project.Api/Services/RoomService.cs @@ -60,35 +60,59 @@ public async Task> GetPublicRoomsAsync() public async Task CreateRoomAsync(CreateRoomDTO dto) { + _logger.LogInformation("Creating new room..."); + Validate(dto); - // Auto-create deck if DeckId is not provided - string deckId = dto.DeckId; - if (string.IsNullOrWhiteSpace(deckId)) - { - _logger.LogInformation("Creating new deck for room"); - deckId = await _deckApiService.CreateDeck(numOfDecks: 6, enableJokers: false); - _logger.LogInformation("Created deck with ID: {DeckId}", deckId); - } + // // Auto-create deck if DeckId is not provided + // string deckId = dto.DeckId; + // if (string.IsNullOrWhiteSpace(deckId)) + // { + // _logger.LogInformation("Creating new deck for room"); + // deckId = await _deckApiService.CreateDeck(numOfDecks: 6, enableJokers: false); + // _logger.LogInformation("Created deck with ID: {DeckId}", deckId); + // } - var room = new Room + Room room = new() { - Id = Guid.NewGuid(), + Id = Guid.CreateVersion7(), HostId = dto.HostId, IsPublic = dto.IsPublic, GameMode = dto.GameMode, - GameState = dto.GameState, + GameState = "{}", // empty initial state GameConfig = dto.GameConfig, Description = dto.Description, MaxPlayers = dto.MaxPlayers, MinPlayers = dto.MinPlayers, - DeckId = deckId, CreatedAt = DateTime.UtcNow, + DeckId = "", // no deck yet IsActive = true, }; - var createdRoom = await _roomRepository.CreateAsync(room); - return MapToResponseDto(createdRoom); + RoomPlayer hostPlayer = new() + { + Id = Guid.CreateVersion7(), + RoomId = room.Id, + UserId = dto.HostId, + Balance = 1000, + }; + + await using var transaction = await _dbContext.Database.BeginTransactionAsync(); + try + { + Room createdRoom = await _roomRepository.CreateAsync(room); // create room + await _roomPlayerRepository.CreateAsync(hostPlayer); // add host as player + await transaction.CommitAsync(); + + _logger.LogInformation("Successfully created room with ID: {RoomId}", createdRoom.Id); + return MapToResponseDto(createdRoom); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to create room for host {HostId}", dto.HostId); + throw; + } } public async Task UpdateRoomAsync(UpdateRoomDTO dto) @@ -102,12 +126,10 @@ public async Task CreateRoomAsync(CreateRoomDTO dto) existingRoom.HostId = dto.HostId; existingRoom.IsPublic = dto.IsPublic; existingRoom.GameMode = dto.GameMode; - existingRoom.GameState = dto.GameState; existingRoom.GameConfig = dto.GameConfig; existingRoom.Description = dto.Description; existingRoom.MaxPlayers = dto.MaxPlayers; existingRoom.MinPlayers = dto.MinPlayers; - existingRoom.DeckId = dto.DeckId; var updatedRoom = await _roomRepository.UpdateAsync(existingRoom); return updatedRoom is null ? null : MapToResponseDto(updatedRoom); @@ -188,9 +210,6 @@ await _roomRepository.GetByIdAsync(roomId) } // Initialize game state based on game mode - string initialGameState; - string finalGameConfig = room.GameConfig; - switch (room.GameMode.ToLower()) { case GameModes.Blackjack: @@ -203,7 +222,6 @@ await _roomRepository.GetByIdAsync(roomId) JsonSerializer.Deserialize(gameConfigJson) ?? throw new BadRequestException("Invalid game configuration JSON."); GameConfigValidator.ValidateBlackjackConfig(config); - finalGameConfig = gameConfigJson; _logger.LogInformation( "Using custom config for room {RoomId}: StartingBalance={Balance}, MinBet={MinBet}", roomId, @@ -223,72 +241,10 @@ await _roomRepository.GetByIdAsync(roomId) { // Use default config config = new BlackjackConfig(); - finalGameConfig = JsonSerializer.Serialize(config); _logger.LogInformation("Using default config for room {RoomId}", roomId); } - // Batch update all players in the room - await _roomPlayerRepository.UpdatePlayersInRoomAsync( - roomId, - player => - { - player.Balance = config.StartingBalance; - player.Status = Status.Away; // Players start as Away, become Active when they bet - } - ); - - _logger.LogInformation( - "Initialized {PlayerCount} players with starting balance {Balance} in room {RoomId}", - playerCount, - config.StartingBalance, - roomId - ); - - // Initialize game state with BlackjackBettingStage - var blackjackState = new BlackjackState - { - CurrentStage = new BlackjackBettingStage( - DateTimeOffset.UtcNow + config.BettingTimeLimit, - [] - ), - DealerHand = "", - }; - initialGameState = JsonSerializer.Serialize(blackjackState); - - // Create empty hands in the deck for each player and the dealer - if (string.IsNullOrWhiteSpace(room.DeckId)) - { - throw new InternalServerException( - "Room DeckId is null or empty after creation." - ); - } - - _logger.LogInformation( - "Creating empty hands for players and dealer in deck {DeckId}", - room.DeckId - ); - - // Get all players in the room - var players = await _roomPlayerRepository.GetByRoomIdAsync(roomId); - - // Create empty hand for each player (using their UserId as the hand identifier) - foreach (var player in players) - { - await _deckApiService.CreateEmptyHand(room.DeckId, player.UserId.ToString()); - _logger.LogInformation( - "Created empty hand for player {PlayerId} in deck {DeckId}", - player.UserId, - room.DeckId - ); - } - - // Create empty hand for dealer - await _deckApiService.CreateEmptyHand(room.DeckId, "dealer"); - _logger.LogInformation( - "Created empty hand for dealer in deck {DeckId}", - room.DeckId - ); - + await _blackjackService.StartGameAsync(roomId, config); break; default: @@ -300,19 +256,11 @@ await _roomPlayerRepository.UpdatePlayersInRoomAsync( throw new BadRequestException($"Unsupported game mode: {room.GameMode}"); } - // Update room with game state and config - room.GameState = initialGameState; - room.GameConfig = finalGameConfig; - room.StartedAt = DateTime.UtcNow; - room.IsActive = true; - room.Round = 1; - - var updatedRoom = - await _roomRepository.UpdateAsync(room) - ?? throw new InternalServerException("Failed to update room."); - _logger.LogInformation("Successfully started game for room {RoomId}", roomId); - return MapToResponseDto(updatedRoom); + return MapToResponseDto( + await _roomRepository.GetByIdAsync(roomId) + ?? throw new NotFoundException($"Room with ID {roomId} not found.") + ); } public async Task PerformPlayerActionAsync( @@ -561,9 +509,6 @@ private static void Validate(CreateRoomDTO dto) if (string.IsNullOrWhiteSpace(dto.GameMode)) throw new BadRequestException("Game mode is required."); - if (string.IsNullOrWhiteSpace(dto.GameState)) - throw new BadRequestException("Game state is required."); - // DeckId is now optional - it will be auto-created if not provided if (dto.Description?.Length > 500) diff --git a/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs b/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs index 8bbd1b4..5a3f56c 100644 --- a/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs +++ b/Project.App/Project.Api/Utilities/Extensions/BlackjackCardExtensions.cs @@ -12,15 +12,19 @@ public static class BlackjackCardExtensions /// /// Calculates the total value of a hand of blackjack cards. /// - public static int CalculateHandValue(this List hand, int target = 21) + public static int CalculateHandValue( + this List hand, + int target = 21, + bool countFlipped = false + ) { int totalValue = 0; int aceCount = 0; foreach (CardDTO card in hand) { - if (card.IsFaceDown) - continue; // Do not count face-down cards for score calculation + if (card.IsFaceDown && !countFlipped) + continue; // Do not count face-down cards for score calculation unless specified switch (card.Value.ToUpper()) { diff --git a/Project.App/Project.Test/Controllers/RoomControllerTest.cs b/Project.App/Project.Test/Controllers/RoomControllerTest.cs index 1aedc4f..523806c 100644 --- a/Project.App/Project.Test/Controllers/RoomControllerTest.cs +++ b/Project.App/Project.Test/Controllers/RoomControllerTest.cs @@ -267,7 +267,6 @@ public async Task CreateRoom_ReturnsCreatedAtAction_WithRoom() GameMode = "Blackjack", MaxPlayers = 6, MinPlayers = 2, - DeckId = "deck123", }; var createdRoom = new RoomDTO { @@ -277,7 +276,6 @@ public async Task CreateRoom_ReturnsCreatedAtAction_WithRoom() GameMode = createDto.GameMode, MaxPlayers = createDto.MaxPlayers, MinPlayers = createDto.MinPlayers, - DeckId = createDto.DeckId, }; _mockRoomService .Setup(service => service.CreateRoomAsync(createDto)) @@ -478,6 +476,69 @@ public async Task LeaveRoom_ReturnsBadRequest_WhenModelStateIsInvalid() Assert.IsType(result.Result); } + [Fact] + public async Task CreateRoom_ReturnsCreatedAtAction_WithRoom_WhenSuccessful() + { + // Arrange + var createDto = new CreateRoomDTO + { + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = "Blackjack", + MaxPlayers = 4, + MinPlayers = 2, + Description = "A new game", + }; + + var createdRoom = new RoomDTO + { + Id = Guid.NewGuid(), + HostId = createDto.HostId, + IsPublic = createDto.IsPublic, + GameMode = createDto.GameMode, + MaxPlayers = createDto.MaxPlayers, + MinPlayers = createDto.MinPlayers, + Description = createDto.Description, + IsActive = true, + }; + + _mockRoomService + .Setup(service => service.CreateRoomAsync(createDto)) + .ReturnsAsync(createdRoom); + + // Act + var result = await _controller.CreateRoom(createDto); + + // Assert + var createdResult = Assert.IsType(result.Result); + var returnRoom = Assert.IsType(createdResult.Value); + Assert.Equal(createdRoom.Id, returnRoom.Id); + Assert.Equal(nameof(_controller.GetRoomById), createdResult.ActionName); + Assert.Equal(createdRoom.Id, (createdResult.RouteValues ?? [])["id"]); + } + + [Fact] + public async Task CreateRoom_ThrowsNullReferenceException_WhenServiceReturnsNull() + { + // Arrange + var createDto = new CreateRoomDTO + { + HostId = Guid.NewGuid(), + GameMode = "Blackjack", + MaxPlayers = 4, + MinPlayers = 2, + }; + + _mockRoomService + .Setup(service => service.CreateRoomAsync(createDto)) + .ReturnsAsync((RoomDTO)null!); // Force service to return null + + // Act & Assert + await Assert.ThrowsAsync(() => + _controller.CreateRoom(createDto) + ); + } + #endregion #region PUT Tests diff --git a/Project.App/Project.Test/Helpers/IntegrationTestBase.cs b/Project.App/Project.Test/Helpers/IntegrationTestBase.cs index 29c0779..2de9adf 100644 --- a/Project.App/Project.Test/Helpers/IntegrationTestBase.cs +++ b/Project.App/Project.Test/Helpers/IntegrationTestBase.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -29,6 +30,59 @@ public abstract class IntegrationTestBase(WebApplicationFactory factory Converters = { new FlexibleEnumConverterFactory() }, }; + /// + /// Creates a WebApplicationFactory configured for a specific test run, ensuring all + /// created clients and service scopes share the same in-memory database. + /// + /// The unique name for the in-memory database. + /// An action to configure test-specific services, like mocks. + /// A configured WebApplicationFactory. + protected WebApplicationFactory CreateConfiguredWebAppFactory( + string dbName, + Action? testServicesConfiguration = null + ) + { + return _factory.WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration( + (context, configBuilder) => + { + configBuilder.AddInMemoryCollection( + new Dictionary + { + { "Google:ClientId", "dummy-client-id" }, + { "Google:ClientSecret", "dummy-client-secret" }, + } + ); + } + ); + + builder.ConfigureServices(services => + { + // silence logging during tests + services.RemoveAll(); + services.AddSingleton(new NullLoggerFactory()); + + // Allow for additional, test-specific service configurations. + testServicesConfiguration?.Invoke(services); + + // Use a single, consistent in-memory database for the entire test. + services.RemoveAll>(); + services.AddDbContext(options => + { + options + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(warnings => + { + warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning); + }); + }); + }); + }); + } + /// /// Create a test HttpClient with a mocked service and in-memory database. /// @@ -67,7 +121,12 @@ protected HttpClient CreateTestClient( services.RemoveAll>(); services.AddDbContext(options => { - options.UseInMemoryDatabase($"InMemoryTestDb_{Guid.NewGuid()}"); + options + .UseInMemoryDatabase($"InMemoryTestDb_{Guid.NewGuid()}") + .ConfigureWarnings(warnings => + { + warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning); + }); }); }); }) diff --git a/Project.App/Project.Test/Helpers/MockDeckAPIHelper.cs b/Project.App/Project.Test/Helpers/MockDeckAPIHelper.cs new file mode 100644 index 0000000..f3ec7a8 --- /dev/null +++ b/Project.App/Project.Test/Helpers/MockDeckAPIHelper.cs @@ -0,0 +1,222 @@ +using Moq; +using Project.Api.DTOs; +using Project.Api.Services.Interface; + +namespace Project.Test.Helpers; + +public static class MockDeckAPIHelper +{ + /// + /// Create a mock deck service that returns predictable cards and tracks them. + /// + /// + public static Mock CreateMockDeckService( + Queue? predefinedCards = null + ) + { + var mockDeckService = new Mock(); + var handCards = new Dictionary>(); + var playerHandIds = new Dictionary(); // Track player hand IDs + + // Use predefined cards or create a default sequence + var cardSequence = + predefinedCards + ?? new Queue( + [ + new CardDTO + { + Value = "7", + Suit = "HEARTS", + Code = "7H", + Image = "dummy-url", + }, + new CardDTO + { + Value = "8", + Suit = "SPADES", + Code = "8S", + Image = "dummy-url", + }, + new CardDTO + { + Value = "10", + Suit = "DIAMONDS", + Code = "10D", + Image = "dummy-url", + }, + new CardDTO + { + Value = "6", + Suit = "CLUBS", + Code = "6C", + Image = "dummy-url", + }, + new CardDTO + { + Value = "4", + Suit = "HEARTS", + Code = "4H", + Image = "dummy-url", + }, + new CardDTO + { + Value = "5", + Suit = "SPADES", + Code = "5S", + Image = "dummy-url", + }, + new CardDTO + { + Value = "10", + Suit = "HEARTS", + Code = "10H", + Image = "dummy-url", + }, + new CardDTO + { + Value = "KING", + Suit = "DIAMONDS", + Code = "KD", + Image = "dummy-url", + }, + new CardDTO + { + Value = "5", + Suit = "DIAMONDS", + Code = "5D", + Image = "dummy-url", + }, + new CardDTO + { + Value = "4", + Suit = "HEARTS", + Code = "4H", + Image = "dummy-url", + }, + new CardDTO + { + Value = "6", + Suit = "SPADES", + Code = "6S", + Image = "dummy-url", + }, + ] + ); + + // Mock deck creation + mockDeckService + .Setup(m => m.CreateDeck(It.IsAny(), It.IsAny())) + .ReturnsAsync("test-deck-id"); + + // Mock creating empty hand - store the hand ID + mockDeckService + .Setup(m => m.CreateEmptyHand(It.IsAny(), It.IsAny())) + .ReturnsAsync( + (string _, string handId) => + { + handCards[handId] = []; + return true; + } + ); + + // Mock creating empty hand with long ID (for player hands) + mockDeckService + .Setup(m => m.CreateEmptyHand(It.IsAny(), It.IsAny())) + .ReturnsAsync( + (string _, long handId) => + { + var handIdStr = handId.ToString(); + handCards[handIdStr] = []; + return true; + } + ); + + // Mock drawing cards - track cards in hands + mockDeckService + .Setup(m => m.DrawCards(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync( + (string _, string handId, int count) => + { + var cards = new List(); + for (int i = 0; i < count; i++) + { + if (cardSequence.Count == 0) + { + throw new InvalidOperationException( + "No more cards in predefined sequence" + ); + } + var card = cardSequence.Dequeue(); + cards.Add(card); + + if (!handCards.TryGetValue(handId, out List? value)) + { + value = []; + handCards[handId] = value; + } + + value.Add(card); + } + return cards; + } + ); + + // Mock adding cards to hand (and implicitly create hand if it doesn't exist) + mockDeckService + .Setup(m => m.AddToHand(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync( + (string _, string handId, string cardCodes) => + { + // Ensure hand exists (create if not) + if (!handCards.TryGetValue(handId, out List? value)) + { + value = []; + handCards[handId] = value; + } + + // Add cards to hand + foreach (var code in cardCodes.Split(',')) + { + value.Add( + new CardDTO + { + Value = "10", + Suit = "HEARTS", + Code = code, + Image = "dummy-url", + } + ); + } + return true; + } + ); + + // Mock listing hands - return tracked cards + mockDeckService + .Setup(m => m.ListHand(It.IsAny(), It.IsAny())) + .ReturnsAsync( + (string _, string handId) => + { + if (!handCards.TryGetValue(handId, out List? value)) + { + value = []; + handCards[handId] = value; + } + return value; + } + ); + + // Mock returning cards to deck + mockDeckService + .Setup(m => m.ReturnAllCardsToDeck(It.IsAny(), It.IsAny())) + .ReturnsAsync( + (string _, bool _) => + { + handCards.Clear(); + return true; + } + ); + + return mockDeckService; + } +} diff --git a/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs b/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs new file mode 100644 index 0000000..e8a0a2c --- /dev/null +++ b/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs @@ -0,0 +1,563 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Project.Api; +using Project.Api.Data; +using Project.Api.DTOs; +using Project.Api.Models; +using Project.Api.Models.Games; +using Project.Api.Services; +using Project.Api.Services.Interface; +using Project.Api.Utilities.Constants; +using Project.Api.Utilities.Enums; +using Project.Test.Helpers; +using Xunit; + +namespace Project.Test.Integration; + +public class BlackjackIntegrationTests(WebApplicationFactory factory) + : IntegrationTestBase(factory) +{ + /// + /// Simulates a complete blackjack game flow with three players for two rounds. + /// + [Fact] + public async Task BlackjackGameFlow_ShouldCompleteSuccessfully() + { + // --- SETUP --- + var sseService = new RoomSSEService(); + + var cardSequence = new Queue( + [ + // Initial deal (in order: P0 -> P1 -> P2 -> Dealer -> P0 -> P1 -> P2 -> Dealer) + new CardDTO + { + Value = "5", + Suit = "HEARTS", + Code = "5H", + Image = "card01", + }, // Player 0 first card + new CardDTO + { + Value = "10", + Suit = "DIAMONDS", + Code = "10D", + Image = "card03", + }, // Player 1 first card + new CardDTO + { + Value = "4", + Suit = "HEARTS", + Code = "4H", + Image = "card05", + }, // Player 2 first card + new CardDTO + { + Value = "10", + Suit = "HEARTS", + Code = "10H", + Image = "card07", + }, // Dealer up card + new CardDTO + { + Value = "6", + Suit = "SPADES", + Code = "6S", + Image = "card02", + }, // Player 0 second card (total: 11) + new CardDTO + { + Value = "6", + Suit = "CLUBS", + Code = "6C", + Image = "card04", + }, // Player 1 second card (total: 16) + new CardDTO + { + Value = "5", + Suit = "SPADES", + Code = "5S", + Image = "card06", + }, // Player 2 second card (total: 9) + new CardDTO + { + Value = "8", + Suit = "DIAMONDS", + Code = "8D", + Image = "card08", + }, // Dealer hole card (total: 18) + // Action phase cards + new CardDTO + { + Value = "8", + Suit = "DIAMONDS", + Code = "8D", + Image = "card09", + }, // Player 0 hit card (11+8=19) + new CardDTO + { + Value = "3", + Suit = "HEARTS", + Code = "3H", + Image = "dcard10", + }, // Player 1 double card (16+3=19) + // No more cards needed: + // - Player 2 surrenders (no cards needed) + // - Dealer has 18, no additional draws needed + ] + ); + var mockDeckService = MockDeckAPIHelper.CreateMockDeckService(cardSequence); + + var dbName = $"InMemoryDb_{Guid.NewGuid()}"; + + var appFactory = CreateConfiguredWebAppFactory( + dbName, + services => + { + // Replace IRoomSSEService with test instance + services.RemoveAll(); + services.AddSingleton(sseService); + + // Replace IDeckApiService with mock + services.RemoveAll(); + services.AddSingleton(mockDeckService.Object); + } + ); + + // --- CREATING A ROOM --- + + // ARRANGE: + + var players = + new List<( + Guid id, + HttpClient client, + StreamReader reader, + CancellationTokenSource cts + )>(); + + // create first player + var player0Id = Guid.CreateVersion7(); + var client0 = appFactory.CreateClient(); + + await using (var scope = appFactory.Services.CreateAsyncScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Users.Add( + new User + { + Id = player0Id, + Name = "HostPlayer", + Email = "host@example.com", + } + ); + await context.SaveChangesAsync(); + } + + // ACT: create new room + var createRoomResponse = await client0.PostAsJsonAsync( + "/api/room", + new CreateRoomDTO + { + HostId = player0Id, + GameMode = GameModes.Blackjack, + MaxPlayers = 3, + }, + _jsonOptions + ); + createRoomResponse.EnsureSuccessStatusCode(); + + // ASSERT: room was created + var createdRoom = await createRoomResponse.Content.ReadFromJsonAsync(); + createdRoom.Should().NotBeNull(); + createdRoom!.Id.Should().NotBeEmpty(); + createdRoom!.HostId.Should().Be(player0Id); + + // ASSERT: room exists + var roomId = createdRoom!.Id; + var roomExistsResponse = await client0.GetAsync($"/api/room/{roomId}/exists"); + roomExistsResponse.EnsureSuccessStatusCode(); + var roomExists = await roomExistsResponse.Content.ReadFromJsonAsync(); + roomExists.Should().BeTrue(); + + // --- CONNECTING TO SSE --- + + // connect first player to SSE + var (reader0, cts0) = await client0.OpenSseConnection(roomId); + players.Add((player0Id, client0, reader0, cts0)); + + // create other two players and connect to SSE + for (int i = 1; i < 3; i++) + { + var userId = Guid.CreateVersion7(); + var client = appFactory.CreateClient(); + var (reader, cts) = await client.OpenSseConnection(roomId); + players.Add((userId, client, reader, cts)); + + // add to user database + await using var scope = appFactory.Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Users.Add( + new User + { + Id = userId, + Name = $"Player{i}", + Email = $"{i}@example.com", + } + ); + await context.SaveChangesAsync(); + } + + // --- ADDING PLAYERS TO ROOM --- + + for (int i = 1; i < players.Count; i++) + { + var (playerId, client, _, _) = players[i]; + var joinRoomResponse = await client.PostAsJsonAsync( + $"/api/room/{roomId}/join", + new { UserId = playerId }, + _jsonOptions + ); + joinRoomResponse.EnsureSuccessStatusCode(); + } + + // --- STARTING THE GAME --- + var startGameResponse = await client0.PostAsJsonAsync( + $"/api/room/{roomId}/start", + null!, // use default options + _jsonOptions + ); + startGameResponse.EnsureSuccessStatusCode(); + + // ASSERT: Game started and betting stage began + foreach (var (_, _, reader, _) in players) + { + var (eventType, eventData) = await SseTestHelper.ReadSseEventAsync(reader); + eventType.Should().Be(RoomEventType.GameStateUpdate); + var gameStateUpdate = SseTestHelper.Deserialize(eventData); + gameStateUpdate!.CurrentStage.Should().BeOfType(); + } + + // --- BETTING --- + + // All players bet 100 chips + for (int i = 0; i < players.Count; i++) + { + var (playerId, client, _, _) = players[i]; + var betResponse = await client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{playerId}/action", + new { Action = "bet", Data = new { Amount = 100 } }, + _jsonOptions + ); + betResponse.EnsureSuccessStatusCode(); + } + + // ASSERT: All players receive bet actions and game state transitions to dealing + foreach (var (_, _, reader, _) in players) + { + // Should get state transition after each bet + for (int i = 0; i < players.Count; i++) + { + // First get the bet action + var (actionEventType, actionEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + actionEventType.Should().Be(RoomEventType.PlayerAction); + var action = SseTestHelper.Deserialize(actionEventData); + action!.Action.Should().Be("bet"); + action.Amount.Should().Be(100); + + // Then get the state update + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var state = SseTestHelper.Deserialize(stateEventData); + state!.CurrentStage.Should().BeOfType(); + } + + // First get dealing stage transition + var (dealingStateEventType, dealingStateData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealingStateEventType.Should().Be(RoomEventType.GameStateUpdate); + var dealingState = SseTestHelper.Deserialize( + dealingStateData + ); + dealingState!.CurrentStage.Should().BeOfType(); + + // Should get player hands being dealt + for (int i = 0; i < players.Count; i++) + { + // Each player gets their full hand of 2 cards at once + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal.Should().NotBeNull(); + reveal!.PlayerHand.Should().NotBeNull(); + reveal.PlayerHand.Count.Should().Be(2); // Players get both cards at once + } + + // Dealer gets two cards (second one face down) + var (dealerRevealEventType, dealerRevealData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealerRevealEventType.Should().Be(RoomEventType.DealerReveal); + var dealerReveal = SseTestHelper.Deserialize(dealerRevealData); + dealerReveal.Should().NotBeNull(); + dealerReveal!.DealerHand.Count.Should().Be(2); + dealerReveal.DealerHand[1].IsFaceDown.Should().BeTrue(); + + // Should transition to player action stage + var (actionStateEventType, actionStateData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + actionStateEventType.Should().Be(RoomEventType.GameStateUpdate); + var actionState = SseTestHelper.Deserialize(actionStateData); + var actionStage = actionState! + .CurrentStage.Should() + .BeOfType() + .Subject; + actionStage.PlayerIndex.Should().Be(0); + actionStage.HandIndex.Should().Be(0); + } + + // --- PLAYER ACTIONS --- + + // Player 0 hits + var hitResponse = await players[0] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[0].id}/action", + new { Action = "hit", Data = new { } }, + _jsonOptions + ); + hitResponse.EnsureSuccessStatusCode(); + + // ASSERT: Hit action broadcast and stay on same player + foreach (var (_, _, reader, _) in players) + { + // First get the hit action event + var (hitEventType, hitEventData) = await SseTestHelper.ReadSseEventAsync(reader); + hitEventType.Should().Be(RoomEventType.PlayerAction); + var hitAction = SseTestHelper.Deserialize(hitEventData); + hitAction!.Action.Should().Be("hit"); + hitAction.Cards.Should().NotBeEmpty(); + + // Then get the player's updated hand reveal + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync(reader); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerHand.Count.Should().Be(3); + + // Finally get the state update showing we stay on the same player + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync(reader); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var gameState = SseTestHelper.Deserialize(stateEventData); + var stage = gameState! + .CurrentStage.Should() + .BeOfType() + .Subject; + + // After a hit that doesn't bust, we should stay on the same player and hand + stage.PlayerIndex.Should().Be(0); + stage.HandIndex.Should().Be(0); + } + + // Player 0 stands + var standResponse = await players[0] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[0].id}/action", + new { Action = "stand", Data = new { } }, + _jsonOptions + ); + standResponse.EnsureSuccessStatusCode(); + + // ASSERT: Stand action broadcast and turn changes to player 1 + foreach (var (_, _, reader, _) in players) + { + await AssertPlayerActionAndTurnChange( + reader, + "stand", + players[0].id, + nextPlayerIndex: 1 + ); + } + + // Player 1 doubles + var doubleResponse = await players[1] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[1].id}/action", + new { Action = "double", Data = new { } }, + _jsonOptions + ); + doubleResponse.EnsureSuccessStatusCode(); + + // ASSERT: Double action broadcast and turn changes to player 2 + foreach (var (_, _, reader, _) in players) + { + // First get the double action event + var (doubleEventType, doubleEventData) = await SseTestHelper.ReadSseEventAsync(reader); + doubleEventType.Should().Be(RoomEventType.PlayerAction); + var doubleAction = SseTestHelper.Deserialize(doubleEventData); + doubleAction!.Action.Should().Be("double"); + doubleAction.PlayerId.Should().Be(players[1].id); + doubleAction.Cards.Should().NotBeEmpty(); + + // Then get the player's updated hand reveal + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync(reader); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerId.Should().Be(players[1].id); + reveal.PlayerHand.Count.Should().Be(3); + + // Finally get the state update showing turn change + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync(reader); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var gameState = SseTestHelper.Deserialize(stateEventData); + var stage = gameState! + .CurrentStage.Should() + .BeOfType() + .Subject; + stage.PlayerIndex.Should().Be(2); + stage.HandIndex.Should().Be(0); + } + + // Player 2 surrenders + var surrenderResponse = await players[2] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[2].id}/action", + new { Action = "surrender", Data = new { } }, + _jsonOptions + ); + surrenderResponse.EnsureSuccessStatusCode(); + + // ASSERT: Surrender action broadcast + foreach (var (_, _, reader, _) in players) + { + // Get the surrender action event + var (surrenderEventType, surrenderEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + surrenderEventType.Should().Be(RoomEventType.PlayerAction); + var surrenderAction = SseTestHelper.Deserialize( + surrenderEventData + ); + surrenderAction!.Action.Should().Be("surrender"); + surrenderAction.PlayerId.Should().Be(players[2].id); + } + + // --- ROUND COMPLETION --- + + // ASSERT: Game transitions to finish round stage + foreach (var (_, _, reader, _) in players) + { + var (finishEventType, finishEventData) = await SseTestHelper.ReadSseEventAsync(reader); + finishEventType.Should().Be(RoomEventType.GameStateUpdate); + var finishState = SseTestHelper.Deserialize(finishEventData); + finishState!.CurrentStage.Should().BeOfType(); + + // Should reveal dealer's full hand + var (dealerRevealEventType, dealerRevealData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealerRevealEventType.Should().Be(RoomEventType.DealerReveal); + var dealerReveal = SseTestHelper.Deserialize(dealerRevealData); + dealerReveal!.DealerHand.Should().NotContain(c => c.IsFaceDown); + + // Should reveal all player hands + for (int i = 0; i < players.Count; i++) + { + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + } + + // Should transition to next betting round + var (nextRoundEventType, nextRoundEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + nextRoundEventType.Should().Be(RoomEventType.GameStateUpdate); + var nextRoundState = SseTestHelper.Deserialize( + nextRoundEventData + ); + nextRoundState!.CurrentStage.Should().BeOfType(); + } + + // Clean up SSE connections + foreach (var (_, _, _, cts) in players) + { + cts.Cancel(); + } + } + + private static async Task AssertPlayerActionAndTurnChange( + StreamReader reader, + string expectedAction, + Guid expectedPlayerId, + int nextPlayerIndex + ) + { + // Assert: Player action was broadcast + var (actionEventType, actionEventData) = await SseTestHelper.ReadSseEventAsync(reader); + actionEventType.Should().Be(RoomEventType.PlayerAction); + var playerAction = SseTestHelper.Deserialize(actionEventData); + playerAction!.Action.Should().Be(expectedAction); + playerAction.PlayerId.Should().Be(expectedPlayerId); + + // Assert: Game state updated to next player's turn + var (turnChangeEventType, turnChangeEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + turnChangeEventType.Should().Be(RoomEventType.GameStateUpdate); + var turnChangeUpdate = SseTestHelper.Deserialize( + turnChangeEventData + ); + var nextActionStage = turnChangeUpdate! + .CurrentStage.Should() + .BeOfType() + .Subject; + nextActionStage.PlayerIndex.Should().Be(nextPlayerIndex); + } + + private static class SseTestHelper + { + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public static async Task<(RoomEventType type, string data)> ReadSseEventAsync( + StreamReader reader + ) + { + var eventLine = await reader.ReadLineAsync(); + var dataLine = await reader.ReadLineAsync(); + await reader.ReadLineAsync(); // consume blank line + + if (eventLine == null || dataLine == null) + { + throw new EndOfStreamException("SSE stream ended unexpectedly."); + } + + var eventTypeString = eventLine.Replace("event: ", ""); + Enum.TryParse(eventTypeString, true, out var eventType) + .Should() + .BeTrue($"because '{eventTypeString}' should be a valid RoomEventType"); + + return (eventType, dataLine.Replace("data: ", "")); + } + + public static T? Deserialize(string data) + { + return JsonSerializer.Deserialize(data, _jsonOptions); + } + } +} diff --git a/Project.App/Project.Test/Services/BlackjackServiceTest.cs b/Project.App/Project.Test/Services/BlackjackServiceTest.cs index 8c794f1..76be717 100644 --- a/Project.App/Project.Test/Services/BlackjackServiceTest.cs +++ b/Project.App/Project.Test/Services/BlackjackServiceTest.cs @@ -30,6 +30,12 @@ public BlackjackServiceTest() _deckApiServiceMock = new Mock(); _roomSSEServiceMock = new Mock(); + var defaultConfig = new BlackjackConfig(); + var defaultConfigString = JsonSerializer.Serialize(defaultConfig); + _roomRepositoryMock + .Setup(r => r.GetGameConfigAsync(It.IsAny())) + .ReturnsAsync(defaultConfigString); + _blackjackService = new BlackjackService( _roomRepositoryMock.Object, _roomPlayerRepositoryMock.Object, diff --git a/Project.App/Project.Test/Services/RoomServiceTest.cs b/Project.App/Project.Test/Services/RoomServiceTest.cs index 8f464cb..68e806f 100644 --- a/Project.App/Project.Test/Services/RoomServiceTest.cs +++ b/Project.App/Project.Test/Services/RoomServiceTest.cs @@ -15,7 +15,7 @@ using Project.Api.Utilities.Enums; using Project.Test.Helpers; -namespace Project.Test.Tests.Services; +namespace Project.Test.Services; public class RoomServiceTest { @@ -33,8 +33,23 @@ public RoomServiceTest() _roomPlayerRepositoryMock = new Mock(); _blackjackServiceMock = new Mock(); _deckApiServiceMock = new Mock(); + + // Configure DbContext to ignore transaction warnings for in-memory provider + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => + w.Ignore( + Microsoft + .EntityFrameworkCore + .Diagnostics + .InMemoryEventId + .TransactionIgnoredWarning + ) + ) + .Options; + _dbContext = new AppDbContext(options); + _loggerMock = new Mock>(); - _dbContext = RepositoryTestHelper.CreateInMemoryContext(); _roomService = new RoomService( _roomRepositoryMock.Object, @@ -42,7 +57,7 @@ public RoomServiceTest() _blackjackServiceMock.Object, _deckApiServiceMock.Object, _dbContext, - _loggerMock.Object + new Mock>().Object ); } @@ -192,87 +207,87 @@ public async Task GetRoomByHostIdAsync_ReturnsNull_WhenRoomDoesNotExist() #region CreateRoomAsync Tests - [Fact] - public async Task CreateRoomAsync_CreatesRoom_WithProvidedDeckId() - { - // Arrange - var hostId = Guid.NewGuid(); - var deckId = "test-deck-123"; - var createDto = new CreateRoomDTO - { - HostId = hostId, - IsPublic = true, - GameMode = GameModes.Blackjack, - GameState = "{}", - GameConfig = "{}", - Description = "Test Room", - MaxPlayers = 6, - MinPlayers = 2, - DeckId = deckId, - }; - - var createdRoom = RepositoryTestHelper.CreateTestRoom( - hostId: hostId, - gameMode: GameModes.Blackjack - ); - _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(createdRoom); - - // Act - var result = await _roomService.CreateRoomAsync(createDto); - - // Assert - result.Should().NotBeNull(); - result.HostId.Should().Be(hostId); - _roomRepositoryMock.Verify( - r => r.CreateAsync(It.Is(room => room.HostId == hostId && room.DeckId == deckId)), - Times.Once - ); - _deckApiServiceMock.Verify( - d => d.CreateDeck(It.IsAny(), It.IsAny()), - Times.Never - ); - } - - [Fact] - public async Task CreateRoomAsync_CreatesRoom_WithAutoDeckCreation_WhenDeckIdNotProvided() - { - // Arrange - var hostId = Guid.NewGuid(); - var autoDeckId = "auto-created-deck-456"; - var createDto = new CreateRoomDTO - { - HostId = hostId, - IsPublic = true, - GameMode = GameModes.Blackjack, - GameState = "{}", - GameConfig = "{}", - Description = "Test Room", - MaxPlayers = 6, - MinPlayers = 2, - DeckId = "", // Empty DeckId should trigger auto-creation - }; - - _deckApiServiceMock.Setup(d => d.CreateDeck(6, false)).ReturnsAsync(autoDeckId); - - var createdRoom = RepositoryTestHelper.CreateTestRoom( - hostId: hostId, - gameMode: GameModes.Blackjack - ); - createdRoom.DeckId = autoDeckId; - _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(createdRoom); - - // Act - var result = await _roomService.CreateRoomAsync(createDto); - - // Assert - result.Should().NotBeNull(); - result.HostId.Should().Be(hostId); - _deckApiServiceMock.Verify(d => d.CreateDeck(6, false), Times.Once); - _roomRepositoryMock.Verify( - r => r.CreateAsync(It.Is(room => room.DeckId == autoDeckId)), - Times.Once - ); - } + // deck id is not set on room creation anymore, so this test is obsolete + // [Fact] + // public async Task CreateRoomAsync_CreatesRoom_WithProvidedDeckId() + // { + // // Arrange + // var hostId = Guid.NewGuid(); + // var deckId = "test-deck-123"; + // var createDto = new CreateRoomDTO + // { + // HostId = hostId, + // IsPublic = true, + // GameMode = GameModes.Blackjack, + // GameState = "{}", + // GameConfig = "{}", + // Description = "Test Room", + // MaxPlayers = 6, + // MinPlayers = 2, + // }; + + // var createdRoom = RepositoryTestHelper.CreateTestRoom( + // hostId: hostId, + // gameMode: GameModes.Blackjack + // ); + // _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(createdRoom); + + // // Act + // var result = await _roomService.CreateRoomAsync(createDto); + + // // Assert + // result.Should().NotBeNull(); + // result.HostId.Should().Be(hostId); + // _roomRepositoryMock.Verify( + // r => r.CreateAsync(It.Is(room => room.HostId == hostId && room.DeckId == deckId)), + // Times.Once + // ); + // _deckApiServiceMock.Verify( + // d => d.CreateDeck(It.IsAny(), It.IsAny()), + // Times.Never + // ); + // } + + // deck id is not set on room creation anymore, so this test is obsolete + // [Fact] + // public async Task CreateRoomAsync_CreatesRoom_WithAutoDeckCreation_WhenDeckIdNotProvided() + // { + // // Arrange + // var hostId = Guid.NewGuid(); + // var autoDeckId = "auto-created-deck-456"; + // var createDto = new CreateRoomDTO + // { + // HostId = hostId, + // IsPublic = true, + // GameMode = GameModes.Blackjack, + // GameState = "{}", + // GameConfig = "{}", + // Description = "Test Room", + // MaxPlayers = 6, + // MinPlayers = 2, + // }; + + // _deckApiServiceMock.Setup(d => d.CreateDeck(6, false)).ReturnsAsync(autoDeckId); + + // var createdRoom = RepositoryTestHelper.CreateTestRoom( + // hostId: hostId, + // gameMode: GameModes.Blackjack + // ); + // createdRoom.DeckId = autoDeckId; + // _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(createdRoom); + + // // Act + // var result = await _roomService.CreateRoomAsync(createDto); + + // // Assert + // result.Should().NotBeNull(); + // result.HostId.Should().Be(hostId); + // _deckApiServiceMock.Verify(d => d.CreateDeck(6, false), Times.Once); + // _roomRepositoryMock.Verify( + // r => r.CreateAsync(It.Is(room => room.DeckId == autoDeckId)), + // Times.Once + // ); + // } [Fact] public async Task CreateRoomAsync_ThrowsBadRequestException_WhenMinPlayersLessThanOne() @@ -283,11 +298,9 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenMinPlayersLessTh HostId = Guid.NewGuid(), IsPublic = true, GameMode = GameModes.Blackjack, - GameState = "{}", GameConfig = "{}", MaxPlayers = 6, MinPlayers = 0, // Invalid - DeckId = "test-deck", }; // Act & Assert @@ -305,11 +318,9 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenMaxPlayersLessTh HostId = Guid.NewGuid(), IsPublic = true, GameMode = GameModes.Blackjack, - GameState = "{}", GameConfig = "{}", MaxPlayers = 2, MinPlayers = 5, // Invalid - greater than MaxPlayers - DeckId = "test-deck", }; // Act & Assert @@ -327,11 +338,9 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenGameModeIsEmpty( HostId = Guid.NewGuid(), IsPublic = true, GameMode = "", // Invalid - GameState = "{}", GameConfig = "{}", MaxPlayers = 6, MinPlayers = 2, - DeckId = "test-deck", }; // Act & Assert @@ -349,12 +358,10 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenDescriptionTooLo HostId = Guid.NewGuid(), IsPublic = true, GameMode = GameModes.Blackjack, - GameState = "{}", GameConfig = "{}", Description = new string('a', 501), // 501 characters - too long MaxPlayers = 6, MinPlayers = 2, - DeckId = "test-deck", }; // Act & Assert @@ -379,12 +386,10 @@ public async Task UpdateRoomAsync_UpdatesRoom_WhenRoomExists() HostId = existingRoom.HostId, IsPublic = false, // Changed GameMode = GameModes.Blackjack, - GameState = "{}", GameConfig = "{}", Description = "Updated Description", MaxPlayers = 8, MinPlayers = 3, - DeckId = "test-deck", }; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(existingRoom); @@ -421,11 +426,9 @@ public async Task UpdateRoomAsync_ReturnsNull_WhenRoomDoesNotExist() HostId = Guid.NewGuid(), IsPublic = true, GameMode = GameModes.Blackjack, - GameState = "{}", GameConfig = "{}", MaxPlayers = 6, MinPlayers = 2, - DeckId = "test-deck", }; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync((Room?)null); @@ -635,53 +638,35 @@ public async Task StartGameAsync_StartsBlackjackGame_WithDefaultConfig() { // Arrange var roomId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom( - id: roomId, - gameMode: GameModes.Blackjack, - minPlayers: 2, - maxPlayers: 6 - ); - room.StartedAt = null; // Game not started yet - room.DeckId = "test-deck-123"; - - var players = new List + var room = new Room { - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId, userId: Guid.NewGuid()), - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId, userId: Guid.NewGuid()), + Id = roomId, + GameMode = GameModes.Blackjack, + GameState = "", + MinPlayers = 1, + GameConfig = "", }; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - _roomPlayerRepositoryMock.Setup(r => r.GetByRoomIdAsync(roomId)).ReturnsAsync(players); - _roomPlayerRepositoryMock - .Setup(r => r.UpdatePlayersInRoomAsync(roomId, It.IsAny>())) + _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); + _blackjackServiceMock + .Setup(bs => bs.StartGameAsync(roomId, It.IsAny())) .Returns(Task.CompletedTask); - _roomRepositoryMock.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(room); - _deckApiServiceMock - .Setup(d => d.CreateEmptyHand(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); // Act - var result = await _roomService.StartGameAsync(roomId); + await _roomService.StartGameAsync(roomId, null); // Assert - result.Should().NotBeNull(); - _roomRepositoryMock.Verify( - r => - r.UpdateAsync( - It.Is(r => r.StartedAt != null && r.Round == 1 && r.IsActive == true) + _blackjackServiceMock.Verify( + bs => + bs.StartGameAsync( + roomId, + It.Is(c => + c.StartingBalance == new BlackjackConfig().StartingBalance + ) ), Times.Once ); - _roomPlayerRepositoryMock.Verify( - r => r.UpdatePlayersInRoomAsync(roomId, It.IsAny>()), - Times.Once - ); - _deckApiServiceMock.Verify( - d => d.CreateEmptyHand("test-deck-123", It.IsAny()), - Times.Exactly(3) // 2 players + 1 dealer - ); - _deckApiServiceMock.Verify(d => d.CreateEmptyHand("test-deck-123", "dealer"), Times.Once); } [Fact] @@ -689,51 +674,31 @@ public async Task StartGameAsync_StartsBlackjackGame_WithCustomConfig() { // Arrange var roomId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom( - id: roomId, - gameMode: GameModes.Blackjack, - minPlayers: 2, - maxPlayers: 6 - ); - room.StartedAt = null; - room.DeckId = "test-deck-123"; - - var customConfig = new BlackjackConfig + var room = new Room { - StartingBalance = 5000, - MinBet = 100, - BettingTimeLimit = TimeSpan.FromSeconds(120), + Id = roomId, + GameState = "", + GameMode = GameModes.Blackjack, + MinPlayers = 1, }; + var customConfig = new BlackjackConfig { StartingBalance = 5000 }; var customConfigJson = JsonSerializer.Serialize(customConfig); - var players = new List - { - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId, userId: Guid.NewGuid()), - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId, userId: Guid.NewGuid()), - }; - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - _roomPlayerRepositoryMock.Setup(r => r.GetByRoomIdAsync(roomId)).ReturnsAsync(players); - _roomPlayerRepositoryMock - .Setup(r => r.UpdatePlayersInRoomAsync(roomId, It.IsAny>())) + _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); + _blackjackServiceMock + .Setup(bs => bs.StartGameAsync(roomId, It.IsAny())) .Returns(Task.CompletedTask); - _roomRepositoryMock.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(room); - _deckApiServiceMock - .Setup(d => d.CreateEmptyHand(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); // Act - var result = await _roomService.StartGameAsync(roomId, customConfigJson); + await _roomService.StartGameAsync(roomId, customConfigJson); // Assert - result.Should().NotBeNull(); - _roomRepositoryMock.Verify( - r => - r.UpdateAsync( - It.Is(r => - r.StartedAt != null && r.Round == 1 && r.GameConfig == customConfigJson - ) + _blackjackServiceMock.Verify( + bs => + bs.StartGameAsync( + roomId, + It.Is(c => c.StartingBalance == customConfig.StartingBalance) ), Times.Once ); @@ -801,36 +766,37 @@ public async Task StartGameAsync_ThrowsBadRequestException_WhenUnsupportedGameMo await Assert.ThrowsAsync(() => _roomService.StartGameAsync(roomId)); } - [Fact] - public async Task StartGameAsync_ThrowsInternalServerException_WhenDeckIdIsNull() - { - // Arrange - var roomId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom( - id: roomId, - gameMode: GameModes.Blackjack, - minPlayers: 2 - ); - room.StartedAt = null; - room.DeckId = null; // Null DeckId should cause error - - var players = new List - { - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId), - }; - - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - _roomPlayerRepositoryMock.Setup(r => r.GetByRoomIdAsync(roomId)).ReturnsAsync(players); - _roomPlayerRepositoryMock - .Setup(r => r.UpdatePlayersInRoomAsync(roomId, It.IsAny>())) - .Returns(Task.CompletedTask); - - // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.StartGameAsync(roomId) - ); - } + // deck id is not set on room creation anymore, so this test is obsolete + // [Fact] + // public async Task StartGameAsync_ThrowsInternalServerException_WhenDeckIdIsNull() + // { + // // Arrange + // var roomId = Guid.NewGuid(); + // var room = RepositoryTestHelper.CreateTestRoom( + // id: roomId, + // gameMode: GameModes.Blackjack, + // minPlayers: 2 + // ); + // room.StartedAt = null; + // room.DeckId = null; // Null DeckId should cause error + + // var players = new List + // { + // RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId), + // }; + + // _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); + // _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); + // _roomPlayerRepositoryMock.Setup(r => r.GetByRoomIdAsync(roomId)).ReturnsAsync(players); + // _roomPlayerRepositoryMock + // .Setup(r => r.UpdatePlayersInRoomAsync(roomId, It.IsAny>())) + // .Returns(Task.CompletedTask); + + // // Act & Assert + // await Assert.ThrowsAsync(() => + // _roomService.StartGameAsync(roomId) + // ); + // } #endregion @@ -1251,4 +1217,53 @@ await Assert.ThrowsAsync(() => } #endregion + + #region i'm losing it + + [Fact] + public async Task CreateRoomAsync_CreatesRoom_WhenDtoIsValid() + { + // Arrange + var createDto = new CreateRoomDTO + { + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = GameModes.Blackjack, + GameConfig = "{}", + Description = "A valid test room", + MaxPlayers = 4, + MinPlayers = 2, + }; + + var roomToCreate = new Room + { + Id = Guid.NewGuid(), + HostId = createDto.HostId, + GameMode = createDto.GameMode, + GameState = "{}", + MaxPlayers = createDto.MaxPlayers, + MinPlayers = createDto.MinPlayers, + }; + + _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(roomToCreate); + + // Act + var result = await _roomService.CreateRoomAsync(createDto); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(roomToCreate.Id); + result.HostId.Should().Be(createDto.HostId); + _roomRepositoryMock.Verify( + r => + r.CreateAsync( + It.Is(room => + room.HostId == createDto.HostId && room.GameMode == createDto.GameMode + ) + ), + Times.Once + ); + } + + #endregion } From 99d72af15613f5131fb64c94e536d24214ca97a6 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 21:26:28 -0400 Subject: [PATCH 14/18] Extract migrations to extension function and skip if testing --- Project.App/Project.Api/Program.cs | 37 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/Project.App/Project.Api/Program.cs b/Project.App/Project.Api/Program.cs index 1eab774..529aee4 100644 --- a/Project.App/Project.Api/Program.cs +++ b/Project.App/Project.Api/Program.cs @@ -38,7 +38,7 @@ public static void Main(string[] args) // use extension methods to configure services if (!builder.Environment.IsEnvironment("Testing")) - builder.Services.AddDatabase(builder.Configuration); + builder.Services.AddDatabase(builder.Configuration); // do not add database in testing builder.Services.AddApplicationServices(); builder.Services.AddCorsPolicy(); builder.Services.AddAuth(builder.Configuration, builder.Environment); @@ -50,23 +50,8 @@ public static void Main(string[] args) var app = builder.Build(); - // optional/safe auto-migrate in other words our startup wont crash if the our migration model doenst match the DB - using (var scope = app.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - try - { - db.Database.Migrate(); - Log.Information("[Startup] Database migration check complete."); - } - catch (Exception ex) - { - Log.Warning( - ex, - "[Startup] Database migration skipped or failed. Continuing startup." - ); - } - } + if (!builder.Environment.IsEnvironment("Testing")) + app.ApplyMigrations(); // there's no database in testing app.UseMiddleware(); @@ -321,4 +306,20 @@ await response.Content.ReadAsStringAsync() return services; } + + public static void ApplyMigrations(this WebApplication app) + { + // optional/safe auto-migrate in other words our startup wont crash if the our migration model doenst match the DB + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + try + { + db.Database.Migrate(); + Log.Information("[Startup] Database migration check complete."); + } + catch (Exception ex) + { + Log.Warning(ex, "[Startup] Database migration skipped or failed. Continuing startup."); + } + } } From 882e2c5a0525629d0b667f4954f49654b70f36f5 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 21:27:22 -0400 Subject: [PATCH 15/18] Format files --- Project.App/Project.Api/Repositories/UserRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.App/Project.Api/Repositories/UserRepository.cs b/Project.App/Project.Api/Repositories/UserRepository.cs index 82d2389..dca315f 100644 --- a/Project.App/Project.Api/Repositories/UserRepository.cs +++ b/Project.App/Project.Api/Repositories/UserRepository.cs @@ -49,7 +49,7 @@ public async Task UpdateAsync(User user) await _context.SaveChangesAsync(); } - // Update user balance + // Update user balance public async Task UpdateBalanceAsync(User user) { _context.Users.Update(user); From 39bc68943a6f7b05ebc338ebf2cd09450ce51955 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 21:51:58 -0400 Subject: [PATCH 16/18] Add second integration test --- .../Project.Api/Services/BlackjackService.cs | 18 +- .../Integration/BlackjackIntegrationTests.cs | 831 +++++++++++++++++- 2 files changed, 842 insertions(+), 7 deletions(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index a9159f9..071cf3c 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -323,15 +323,15 @@ await _handRepository.GetHandByRoomOrderAsync( actionStage.HandIndex ) ).RoomPlayerId; - RoomPlayer incativePlayer = + RoomPlayer inactivePlayer = await _roomPlayerRepository.GetByIdAsync(inactiveRoomPlayerId) ?? throw new InternalServerException( $"Could not find current player {inactiveRoomPlayerId} in room {roomId}." ); // deadline passed, mark player as inactive - incativePlayer.Status = Status.Inactive; - await _roomPlayerRepository.UpdateAsync(incativePlayer); + inactivePlayer.Status = Status.Inactive; + await _roomPlayerRepository.UpdateAsync(inactivePlayer); // broadcast player action await _roomSSEService.BroadcastPlayerActionAsync( @@ -340,7 +340,7 @@ await _roomSSEService.BroadcastPlayerActionAsync( actionStage.HandIndex, "hurry_up", success: true, - targetPlayerId: incativePlayer.Id + targetPlayerId: inactivePlayer.UserId ); } @@ -476,10 +476,13 @@ await _roomRepository.GetByIdAsync(roomId) deckId, $"hand-{hand.Id}" ); + RoomPlayer roomPlayer = + await _roomPlayerRepository.GetByIdAsync(hand.RoomPlayerId) + ?? throw new InternalServerException($"RoomPlayer {hand.RoomPlayerId} not found."); await playerHandCards.BroadcastAsync( _roomSSEService, roomId, - hand.RoomPlayerId, + roomPlayer.UserId, hand.HandNumber ); // no hidden cards for players } @@ -915,10 +918,13 @@ await _roomPlayerRepository.UpdatePlayerBalanceAsync( } // broadcast final player hand + RoomPlayer roomPlayer = + await _roomPlayerRepository.GetByIdAsync(hand.RoomPlayerId) + ?? throw new InternalServerException($"RoomPlayer {hand.RoomPlayerId} not found."); await playerHand.BroadcastAsync( _roomSSEService, roomId, - hand.RoomPlayerId, + roomPlayer.UserId, hand.HandNumber ); } diff --git a/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs b/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs index e8a0a2c..1ccf3de 100644 --- a/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs +++ b/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs @@ -27,7 +27,7 @@ public class BlackjackIntegrationTests(WebApplicationFactory factory) /// Simulates a complete blackjack game flow with three players for two rounds. /// [Fact] - public async Task BlackjackGameFlow_ShouldCompleteSuccessfully() + public async Task BlackjackGameFlow_OneRound_ShouldCompleteSuccessfully() { // --- SETUP --- var sseService = new RoomSSEService(); @@ -498,6 +498,835 @@ await AssertPlayerActionAndTurnChange( } } + [Fact] + public async Task BlackjackGameFlow_TwoRounds_ShouldCompleteSuccessfully() + { + // --- SETUP --- + var sseService = new RoomSSEService(); + + var cardSequence = new Queue( + [ + // --- ROUND 1 CARDS --- + // Initial deal (in order: P0 -> P1 -> P2 -> Dealer -> P0 -> P1 -> P2 -> Dealer) + new CardDTO + { + Value = "5", + Suit = "HEARTS", + Code = "5H", + Image = "card01", + }, // Player 0 first card + new CardDTO + { + Value = "10", + Suit = "DIAMONDS", + Code = "10D", + Image = "card03", + }, // Player 1 first card + new CardDTO + { + Value = "4", + Suit = "HEARTS", + Code = "4H", + Image = "card05", + }, // Player 2 first card + new CardDTO + { + Value = "10", + Suit = "HEARTS", + Code = "10H", + Image = "card07", + }, // Dealer up card + new CardDTO + { + Value = "6", + Suit = "SPADES", + Code = "6S", + Image = "card02", + }, // Player 0 second card (total: 11) + new CardDTO + { + Value = "6", + Suit = "CLUBS", + Code = "6C", + Image = "card04", + }, // Player 1 second card (total: 16) + new CardDTO + { + Value = "5", + Suit = "SPADES", + Code = "5S", + Image = "card06", + }, // Player 2 second card (total: 9) + new CardDTO + { + Value = "8", + Suit = "DIAMONDS", + Code = "8D", + Image = "card08", + }, // Dealer hole card (total: 18) + // Action phase cards for Round 1 + new CardDTO + { + Value = "8", + Suit = "DIAMONDS", + Code = "8D", + Image = "card09", + }, // Player 0 hit card (11+8=19) + new CardDTO + { + Value = "3", + Suit = "HEARTS", + Code = "3H", + Image = "dcard10", + }, // Player 1 double card (16+3=19) + // No more cards needed for Round 1: + // - Player 2 surrenders (no cards needed) + // - Dealer has 18, no additional draws needed + + // --- ROUND 2 CARDS --- + // Initial deal (P0 -> P1 -> P2 -> Dealer -> P0 -> P1 -> P2 -> Dealer) + new CardDTO + { + Value = "7", + Suit = "HEARTS", + Code = "7H", + Image = "card11", + }, // Player 0 first card + new CardDTO + { + Value = "9", + Suit = "DIAMONDS", + Code = "9D", + Image = "card13", + }, // Player 1 first card + new CardDTO + { + Value = "2", + Suit = "HEARTS", + Code = "2H", + Image = "card15", + }, // Player 2 first card + new CardDTO + { + Value = "6", + Suit = "HEARTS", + Code = "6H", + Image = "card17", + }, // Dealer up card + new CardDTO + { + Value = "8", + Suit = "SPADES", + Code = "8S", + Image = "card12", + }, // Player 0 second card (total: 15) + new CardDTO + { + Value = "5", + Suit = "CLUBS", + Code = "5C", + Image = "card14", + }, // Player 1 second card (total: 14) + new CardDTO + { + Value = "3", + Suit = "SPADES", + Code = "3S", + Image = "card16", + }, // Player 2 second card (total: 5) + new CardDTO + { + Value = "4", + Suit = "DIAMONDS", + Code = "4D", + Image = "card18", + }, // Dealer hole card (total: 10) + // Action phase cards for Round 2 + new CardDTO + { + Value = "10", + Suit = "SPADES", + Code = "10S", + Image = "card19", + }, // Player 2 hit card (5+10=15) + new CardDTO + { + Value = "5", + Suit = "DIAMONDS", + Code = "5D", + Image = "card20", + }, // Dealer hit card (10+5=15) + new CardDTO + { + Value = "3", + Suit = "CLUBS", + Code = "3C", + Image = "card21", + }, // Dealer hit card (15+3=18) + ] + ); + var mockDeckService = MockDeckAPIHelper.CreateMockDeckService(cardSequence); + + var dbName = $"InMemoryDb_{Guid.NewGuid()}"; + + var appFactory = CreateConfiguredWebAppFactory( + dbName, + services => + { + // Replace IRoomSSEService with test instance + services.RemoveAll(); + services.AddSingleton(sseService); + + // Replace IDeckApiService with mock + services.RemoveAll(); + services.AddSingleton(mockDeckService.Object); + } + ); + + // --- CREATING A ROOM --- + + // ARRANGE: + + var players = + new List<( + Guid id, + HttpClient client, + StreamReader reader, + CancellationTokenSource cts + )>(); + + // create first player + var player0Id = Guid.CreateVersion7(); + var client0 = appFactory.CreateClient(); + + await using (var scope = appFactory.Services.CreateAsyncScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Users.Add( + new User + { + Id = player0Id, + Name = "HostPlayer", + Email = "host@example.com", + } + ); + await context.SaveChangesAsync(); + } + + // ACT: create new room + var createRoomResponse = await client0.PostAsJsonAsync( + "/api/room", + new CreateRoomDTO + { + HostId = player0Id, + GameMode = GameModes.Blackjack, + MaxPlayers = 3, + }, + _jsonOptions + ); + createRoomResponse.EnsureSuccessStatusCode(); + + // ASSERT: room was created + var createdRoom = await createRoomResponse.Content.ReadFromJsonAsync(); + createdRoom.Should().NotBeNull(); + createdRoom!.Id.Should().NotBeEmpty(); + createdRoom!.HostId.Should().Be(player0Id); + + // ASSERT: room exists + var roomId = createdRoom!.Id; + var roomExistsResponse = await client0.GetAsync($"/api/room/{roomId}/exists"); + roomExistsResponse.EnsureSuccessStatusCode(); + var roomExists = await roomExistsResponse.Content.ReadFromJsonAsync(); + roomExists.Should().BeTrue(); + + // --- CONNECTING TO SSE --- + + // connect first player to SSE + var (reader0, cts0) = await client0.OpenSseConnection(roomId); + players.Add((player0Id, client0, reader0, cts0)); + + // create other two players and connect to SSE + for (int i = 1; i < 3; i++) + { + var userId = Guid.CreateVersion7(); + var client = appFactory.CreateClient(); + var (reader, cts) = await client.OpenSseConnection(roomId); + players.Add((userId, client, reader, cts)); + + // add to user database + await using var scope = appFactory.Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Users.Add( + new User + { + Id = userId, + Name = $"Player{i}", + Email = $"{i}@example.com", + } + ); + await context.SaveChangesAsync(); + } + + // --- ADDING PLAYERS TO ROOM --- + + for (int i = 1; i < players.Count; i++) + { + var (playerId, client, _, _) = players[i]; + var joinRoomResponse = await client.PostAsJsonAsync( + $"/api/room/{roomId}/join", + new { UserId = playerId }, + _jsonOptions + ); + joinRoomResponse.EnsureSuccessStatusCode(); + } + + // --- STARTING THE GAME --- + var startGameResponse = await client0.PostAsJsonAsync( + $"/api/room/{roomId}/start", + null!, // use default options + _jsonOptions + ); + startGameResponse.EnsureSuccessStatusCode(); + + // ASSERT: Game started and betting stage began + foreach (var (_, _, reader, _) in players) + { + var (eventType, eventData) = await SseTestHelper.ReadSseEventAsync(reader); + eventType.Should().Be(RoomEventType.GameStateUpdate); + var gameStateUpdate = SseTestHelper.Deserialize(eventData); + gameStateUpdate!.CurrentStage.Should().BeOfType(); + } + + // --- ROUND 1 --- + + // --- BETTING (Round 1) --- + + // All players bet 100 chips + for (int i = 0; i < players.Count; i++) + { + var (playerId, client, _, _) = players[i]; + var betResponse = await client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{playerId}/action", + new { Action = "bet", Data = new { Amount = 100 } }, + _jsonOptions + ); + betResponse.EnsureSuccessStatusCode(); + } + + // ASSERT: All players receive bet actions and game state transitions to dealing + foreach (var (_, _, reader, _) in players) + { + // Should get state transition after each bet + for (int i = 0; i < players.Count; i++) + { + // First get the bet action + var (actionEventType, actionEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + actionEventType.Should().Be(RoomEventType.PlayerAction); + var action = SseTestHelper.Deserialize(actionEventData); + action!.Action.Should().Be("bet"); + action.Amount.Should().Be(100); + + // Then get the state update + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var state = SseTestHelper.Deserialize(stateEventData); + state!.CurrentStage.Should().BeOfType(); + } + + // First get dealing stage transition + var (dealingStateEventType, dealingStateData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealingStateEventType.Should().Be(RoomEventType.GameStateUpdate); + var dealingState = SseTestHelper.Deserialize( + dealingStateData + ); + dealingState!.CurrentStage.Should().BeOfType(); + + // Should get player hands being dealt + for (int i = 0; i < players.Count; i++) + { + // Each player gets their full hand of 2 cards at once + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal.Should().NotBeNull(); + reveal!.PlayerHand.Should().NotBeNull(); + reveal.PlayerHand.Count.Should().Be(2); // Players get both cards at once + } + + // Dealer gets two cards (second one face down) + var (dealerRevealEventType, dealerRevealData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealerRevealEventType.Should().Be(RoomEventType.DealerReveal); + var dealerReveal = SseTestHelper.Deserialize(dealerRevealData); + dealerReveal.Should().NotBeNull(); + dealerReveal!.DealerHand.Count.Should().Be(2); + dealerReveal.DealerHand[1].IsFaceDown.Should().BeTrue(); + + // Should transition to player action stage + var (actionStateEventType, actionStateData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + actionStateEventType.Should().Be(RoomEventType.GameStateUpdate); + var actionState = SseTestHelper.Deserialize(actionStateData); + var actionStage = actionState! + .CurrentStage.Should() + .BeOfType() + .Subject; + actionStage.PlayerIndex.Should().Be(0); + actionStage.HandIndex.Should().Be(0); + } + + // --- PLAYER ACTIONS (Round 1) --- + + // Player 0 hits + var hitResponse = await players[0] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[0].id}/action", + new { Action = "hit", Data = new { } }, + _jsonOptions + ); + hitResponse.EnsureSuccessStatusCode(); + + // ASSERT: Hit action broadcast and stay on same player + foreach (var (_, _, reader, _) in players) + { + // First get the hit action event + var (hitEventType, hitEventData) = await SseTestHelper.ReadSseEventAsync(reader); + hitEventType.Should().Be(RoomEventType.PlayerAction); + var hitAction = SseTestHelper.Deserialize(hitEventData); + hitAction!.Action.Should().Be("hit"); + hitAction.Cards.Should().NotBeEmpty(); + + // Then get the player's updated hand reveal + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync(reader); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerHand.Count.Should().Be(3); + + // Finally get the state update showing we stay on the same player + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync(reader); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var gameState = SseTestHelper.Deserialize(stateEventData); + var stage = gameState! + .CurrentStage.Should() + .BeOfType() + .Subject; + + // After a hit that doesn't bust, we should stay on the same player and hand + stage.PlayerIndex.Should().Be(0); + stage.HandIndex.Should().Be(0); + } + + // Player 0 stands + var standResponse = await players[0] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[0].id}/action", + new { Action = "stand", Data = new { } }, + _jsonOptions + ); + standResponse.EnsureSuccessStatusCode(); + + // ASSERT: Stand action broadcast and turn changes to player 1 + foreach (var (_, _, reader, _) in players) + { + await AssertPlayerActionAndTurnChange( + reader, + "stand", + players[0].id, + nextPlayerIndex: 1 + ); + } + + // Player 1 doubles + var doubleResponse = await players[1] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[1].id}/action", + new { Action = "double", Data = new { } }, + _jsonOptions + ); + doubleResponse.EnsureSuccessStatusCode(); + + // ASSERT: Double action broadcast and turn changes to player 2 + foreach (var (_, _, reader, _) in players) + { + // First get the double action event + var (doubleEventType, doubleEventData) = await SseTestHelper.ReadSseEventAsync(reader); + doubleEventType.Should().Be(RoomEventType.PlayerAction); + var doubleAction = SseTestHelper.Deserialize(doubleEventData); + doubleAction!.Action.Should().Be("double"); + doubleAction.PlayerId.Should().Be(players[1].id); + doubleAction.Cards.Should().NotBeEmpty(); + + // Then get the player's updated hand reveal + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync(reader); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerId.Should().Be(players[1].id); + reveal.PlayerHand.Count.Should().Be(3); + + // Finally get the state update showing turn change + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync(reader); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var gameState = SseTestHelper.Deserialize(stateEventData); + var stage = gameState! + .CurrentStage.Should() + .BeOfType() + .Subject; + stage.PlayerIndex.Should().Be(2); + stage.HandIndex.Should().Be(0); + } + + // Player 2 surrenders + var surrenderResponse = await players[2] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[2].id}/action", + new { Action = "surrender", Data = new { } }, + _jsonOptions + ); + surrenderResponse.EnsureSuccessStatusCode(); + + // ASSERT: Surrender action broadcast + foreach (var (_, _, reader, _) in players) + { + // Get the surrender action event + var (surrenderEventType, surrenderEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + surrenderEventType.Should().Be(RoomEventType.PlayerAction); + var surrenderAction = SseTestHelper.Deserialize( + surrenderEventData + ); + surrenderAction!.Action.Should().Be("surrender"); + surrenderAction.PlayerId.Should().Be(players[2].id); + } + + // --- ROUND 1 COMPLETION --- + + // ASSERT: Game transitions to finish round stage + foreach (var (_, _, reader, _) in players) + { + var (finishEventType, finishEventData) = await SseTestHelper.ReadSseEventAsync(reader); + finishEventType.Should().Be(RoomEventType.GameStateUpdate); + var finishState = SseTestHelper.Deserialize(finishEventData); + finishState!.CurrentStage.Should().BeOfType(); + + // Should reveal dealer's full hand + var (dealerRevealEventType, dealerRevealData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealerRevealEventType.Should().Be(RoomEventType.DealerReveal); + var dealerReveal = SseTestHelper.Deserialize(dealerRevealData); + dealerReveal!.DealerHand.Should().NotContain(c => c.IsFaceDown); + dealerReveal.DealerScore.Should().Be(18); // 10 + 8 + + // Should reveal all player hands + for (int i = 0; i < players.Count; i++) + { + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerId.Should().Be(players[i].id); + // P0: 5+6+8 = 19 + // P1: 10+6+3 = 19 + // P2: 4+5 = 9 (surrendered, but hand is revealed) + if (i == 0 || i == 1) + { + reveal.PlayerHand.Count.Should().Be(3); + reveal.PlayerScore.Should().Be(19); + } + else if (i == 2) + { + reveal.PlayerHand.Count.Should().Be(2); + reveal.PlayerScore.Should().Be(9); + } + } + + // Should transition to next betting round + var (nextRoundEventType, nextRoundEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + nextRoundEventType.Should().Be(RoomEventType.GameStateUpdate); + var nextRoundState = SseTestHelper.Deserialize( + nextRoundEventData + ); + nextRoundState!.CurrentStage.Should().BeOfType(); + } + + // --- ROUND 2 --- + + // --- BETTING (Round 2) --- + + // All players bet 100 chips again + for (int i = 0; i < players.Count; i++) + { + var (playerId, client, _, _) = players[i]; + var betResponse = await client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{playerId}/action", + new { Action = "bet", Data = new { Amount = 100 } }, + _jsonOptions + ); + betResponse.EnsureSuccessStatusCode(); + } + + // ASSERT: All players receive bet actions and game state transitions to dealing + foreach (var (_, _, reader, _) in players) + { + // Should get state transition after each bet + for (int i = 0; i < players.Count; i++) + { + // First get the bet action + var (actionEventType, actionEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + actionEventType.Should().Be(RoomEventType.PlayerAction); + var action = SseTestHelper.Deserialize(actionEventData); + action!.Action.Should().Be("bet"); + action.Amount.Should().Be(100); + + // Then get the state update + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var state = SseTestHelper.Deserialize(stateEventData); + state!.CurrentStage.Should().BeOfType(); + } + + // First get dealing stage transition + var (dealingStateEventType, dealingStateData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealingStateEventType.Should().Be(RoomEventType.GameStateUpdate); + var dealingState = SseTestHelper.Deserialize( + dealingStateData + ); + dealingState!.CurrentStage.Should().BeOfType(); + + // Should get player hands being dealt + for (int i = 0; i < players.Count; i++) + { + // Each player gets their full hand of 2 cards at once + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal.Should().NotBeNull(); + reveal!.PlayerHand.Should().NotBeNull(); + reveal.PlayerHand.Count.Should().Be(2); // Players get both cards at once + } + + // Dealer gets two cards (second one face down) + var (dealerRevealEventType, dealerRevealData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealerRevealEventType.Should().Be(RoomEventType.DealerReveal); + var dealerReveal = SseTestHelper.Deserialize(dealerRevealData); + dealerReveal.Should().NotBeNull(); + dealerReveal!.DealerHand.Count.Should().Be(2); + dealerReveal.DealerHand[1].IsFaceDown.Should().BeTrue(); + + // Should transition to player action stage + var (actionStateEventType, actionStateData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + actionStateEventType.Should().Be(RoomEventType.GameStateUpdate); + var actionState = SseTestHelper.Deserialize(actionStateData); + var actionStage = actionState! + .CurrentStage.Should() + .BeOfType() + .Subject; + actionStage.PlayerIndex.Should().Be(0); + actionStage.HandIndex.Should().Be(0); + } + + // --- PLAYER ACTIONS (Round 2) --- + + // Player 0 stands + var standResponse0_R2 = await players[0] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[0].id}/action", + new { Action = "stand", Data = new { } }, + _jsonOptions + ); + standResponse0_R2.EnsureSuccessStatusCode(); + + // ASSERT: Stand action broadcast and turn changes to player 1 + foreach (var (_, _, reader, _) in players) + { + await AssertPlayerActionAndTurnChange( + reader, + "stand", + players[0].id, + nextPlayerIndex: 1 + ); + } + + // Player 1 stands + var standResponse1_R2 = await players[1] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[1].id}/action", + new { Action = "stand", Data = new { } }, + _jsonOptions + ); + standResponse1_R2.EnsureSuccessStatusCode(); + + // ASSERT: Stand action broadcast and turn changes to player 2 + foreach (var (_, _, reader, _) in players) + { + await AssertPlayerActionAndTurnChange( + reader, + "stand", + players[1].id, + nextPlayerIndex: 2 + ); + } + + // Player 2 hits + var hitResponse2_R2 = await players[2] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[2].id}/action", + new { Action = "hit", Data = new { } }, + _jsonOptions + ); + hitResponse2_R2.EnsureSuccessStatusCode(); + + // ASSERT: Hit action broadcast and stay on same player + foreach (var (_, _, reader, _) in players) + { + // First get the hit action event + var (hitEventType, hitEventData) = await SseTestHelper.ReadSseEventAsync(reader); + hitEventType.Should().Be(RoomEventType.PlayerAction); + var hitAction = SseTestHelper.Deserialize(hitEventData); + hitAction!.Action.Should().Be("hit"); + hitAction.Cards.Should().NotBeEmpty(); + + // Then get the player's updated hand reveal + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync(reader); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerHand.Count.Should().Be(3); // 2 initial + 1 hit + reveal.PlayerScore.Should().Be(15); // 2+3+10 = 15 + + // Finally get the state update showing we stay on the same player + var (stateEventType, stateEventData) = await SseTestHelper.ReadSseEventAsync(reader); + stateEventType.Should().Be(RoomEventType.GameStateUpdate); + var gameState = SseTestHelper.Deserialize(stateEventData); + var stage = gameState! + .CurrentStage.Should() + .BeOfType() + .Subject; + + // After a hit that doesn't bust, we should stay on the same player and hand + stage.PlayerIndex.Should().Be(2); + stage.HandIndex.Should().Be(0); + } + + // Player 2 stands + var standResponse2_R2 = await players[2] + .client.PostAsJsonAsync( + $"/api/room/{roomId}/player/{players[2].id}/action", + new { Action = "stand", Data = new { } }, + _jsonOptions + ); + standResponse2_R2.EnsureSuccessStatusCode(); + + // ASSERT: Stand action broadcast and turn changes to finish round + foreach (var (_, _, reader, _) in players) + { + // Assert: Player action was broadcast + var (actionEventType, actionEventData) = await SseTestHelper.ReadSseEventAsync(reader); + actionEventType.Should().Be(RoomEventType.PlayerAction); + var playerAction = SseTestHelper.Deserialize(actionEventData); + playerAction!.Action.Should().Be("stand"); + playerAction.PlayerId.Should().Be(players[2].id); + + // Assert: Game state updated to finish round stage + var (turnChangeEventType, turnChangeEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + turnChangeEventType.Should().Be(RoomEventType.GameStateUpdate); + var turnChangeUpdate = SseTestHelper.Deserialize( + turnChangeEventData + ); + turnChangeUpdate!.CurrentStage.Should().BeOfType(); + } + + // --- ROUND 2 COMPLETION --- + + // ASSERT: Game transitions to finish round stage + foreach (var (_, _, reader, _) in players) + { + // The previous assertion already consumed the BlackjackFinishRoundStage event. + // So we expect the dealer reveal next. + + // Should reveal dealer's full hand + var (dealerRevealEventType, dealerRevealData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + dealerRevealEventType.Should().Be(RoomEventType.DealerReveal); + var dealerReveal = SseTestHelper.Deserialize(dealerRevealData); + dealerReveal!.DealerHand.Should().NotContain(c => c.IsFaceDown); + dealerReveal.DealerScore.Should().Be(18); // 6 + 4 + 5 + 3 + + // Should reveal all player hands + for (int i = 0; i < players.Count; i++) + { + var (revealEventType, revealEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + revealEventType.Should().Be(RoomEventType.PlayerReveal); + var reveal = SseTestHelper.Deserialize(revealEventData); + reveal!.PlayerId.Should().Be(players[i].id); + // P0: 7+8 = 15 + // P1: 9+5 = 14 + // P2: 2+3+10 = 15 + if (i == 0) + { + reveal.PlayerHand.Count.Should().Be(2); + reveal.PlayerScore.Should().Be(15); + } + else if (i == 1) + { + reveal.PlayerHand.Count.Should().Be(2); + reveal.PlayerScore.Should().Be(14); + } + else if (i == 2) + { + reveal.PlayerHand.Count.Should().Be(3); + reveal.PlayerScore.Should().Be(15); + } + } + + // Should transition to next betting round (or teardown if game ends) + var (nextRoundEventType, nextRoundEventData) = await SseTestHelper.ReadSseEventAsync( + reader + ); + nextRoundEventType.Should().Be(RoomEventType.GameStateUpdate); + var nextRoundState = SseTestHelper.Deserialize( + nextRoundEventData + ); + nextRoundState!.CurrentStage.Should().BeOfType(); + } + + // Clean up SSE connections + foreach (var (_, _, _, cts) in players) + { + cts.Cancel(); + } + } + private static async Task AssertPlayerActionAndTurnChange( StreamReader reader, string expectedAction, From 8b2e97c026257cb0ba61c71c0a9e4076d12b2b76 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Thu, 23 Oct 2025 21:57:45 -0400 Subject: [PATCH 17/18] Ensure completed hands are processed in order --- Project.App/Project.Api/Services/BlackjackService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index 071cf3c..ead2edb 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -887,7 +887,12 @@ await _deckApiService.DrawCards( await dealerHand.BroadcastAsync(_roomSSEService, roomId); // calculate winnings for each player hand - List hands = await _handRepository.GetHandsByRoomIdAsync(roomId); + List hands = + [ + .. (await _handRepository.GetHandsByRoomIdAsync(roomId)) + .OrderBy(h => h.Order) + .ThenBy(h => h.HandNumber), + ]; // ensure hands are processed in order foreach (Hand hand in hands) { List playerHand = await _deckApiService.ListHand( From 988caed07f39a6d089433d59db3251746aa55cb6 Mon Sep 17 00:00:00 2001 From: alt-potato Date: Fri, 24 Oct 2025 01:45:08 -0400 Subject: [PATCH 18/18] Refactor room service and shift functionality to game services --- .../Models/Games/BlackjackConfig.cs | 1 + .../Project.Api/Models/Games/GameConfig.cs | 3 + .../Project.Api/Models/Games/RoomEvents.cs | 9 + Project.App/Project.Api/Program.cs | 3 +- .../Project.Api/Services/BlackjackService.cs | 170 ++- .../Services/Interface/IGameService.cs | 17 + .../Project.Api/Services/RoomService.cs | 296 ++--- .../Utilities/Enums/RoomEventType.cs | 5 +- .../Integration/BlackjackIntegrationTests.cs | 32 + .../Services/BlackjackServiceTest.cs | 3 + .../Services/RoomSSEServiceTests.cs | 2 +- .../Project.Test/Services/RoomServiceTest.cs | 1038 ++++++++++------- 12 files changed, 929 insertions(+), 650 deletions(-) diff --git a/Project.App/Project.Api/Models/Games/BlackjackConfig.cs b/Project.App/Project.Api/Models/Games/BlackjackConfig.cs index f8a5828..c5a084f 100644 --- a/Project.App/Project.Api/Models/Games/BlackjackConfig.cs +++ b/Project.App/Project.Api/Models/Games/BlackjackConfig.cs @@ -9,4 +9,5 @@ public record BlackjackConfig : GameConfig public long MinBet { get; set; } = 0; public TimeSpan BettingTimeLimit { get; set; } = TimeSpan.FromSeconds(60); public override TimeSpan TurnTimeLimit { get; set; } = TimeSpan.FromSeconds(30); + public bool AllowBalanceReset { get; set; } = true; } diff --git a/Project.App/Project.Api/Models/Games/GameConfig.cs b/Project.App/Project.Api/Models/Games/GameConfig.cs index f4d0481..fbee25c 100644 --- a/Project.App/Project.Api/Models/Games/GameConfig.cs +++ b/Project.App/Project.Api/Models/Games/GameConfig.cs @@ -1,9 +1,12 @@ +using System.Text.Json.Serialization; + namespace Project.Api.Models.Games; /// /// Represents the configuration for a game. /// Should be extended with settings for a specific game type. /// +[JsonDerivedType(typeof(BlackjackConfig), typeDiscriminator: "blackjack")] public abstract record GameConfig { public virtual int? MaxPlayers { get; set; } diff --git a/Project.App/Project.Api/Models/Games/RoomEvents.cs b/Project.App/Project.Api/Models/Games/RoomEvents.cs index 4e10ecb..0e35c21 100644 --- a/Project.App/Project.Api/Models/Games/RoomEvents.cs +++ b/Project.App/Project.Api/Models/Games/RoomEvents.cs @@ -60,6 +60,15 @@ public record PlayerLeaveEventData : IRoomEventData public string PlayerName { get; set; } = string.Empty; } +/// +/// Specific DTO for a host change event (if the current host leaves) +/// +public record HostChangeEventData : IRoomEventData +{ + public Guid PlayerId { get; set; } + public string PlayerName { get; set; } = string.Empty; +} + /// /// Specific DTO for revealing the dealer's cards /// diff --git a/Project.App/Project.Api/Program.cs b/Project.App/Project.Api/Program.cs index 529aee4..647300f 100644 --- a/Project.App/Project.Api/Program.cs +++ b/Project.App/Project.Api/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.EntityFrameworkCore; using Project.Api.Data; +using Project.Api.Models.Games; using Project.Api.Repositories; using Project.Api.Repositories.Interface; using Project.Api.Services; @@ -129,7 +130,7 @@ IConfiguration configuration public static IServiceCollection AddApplicationServices(this IServiceCollection services) { // scoped services - services.AddScoped(); + services.AddScoped, BlackjackService>(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Project.App/Project.Api/Services/BlackjackService.cs b/Project.App/Project.Api/Services/BlackjackService.cs index ead2edb..0aa47fe 100644 --- a/Project.App/Project.Api/Services/BlackjackService.cs +++ b/Project.App/Project.Api/Services/BlackjackService.cs @@ -5,6 +5,7 @@ using Project.Api.Repositories.Interface; using Project.Api.Services.Interface; using Project.Api.Utilities; +using Project.Api.Utilities.Constants; using Project.Api.Utilities.Enums; using Project.Api.Utilities.Extensions; @@ -75,33 +76,45 @@ public class BlackjackService( IHandRepository handRepository, IDeckApiService deckApiService, IRoomSSEService roomSSEService, + IUserRepository userRepository, ILogger logger -) : IBlackjackService +) : IGameService { private readonly IRoomRepository _roomRepository = roomRepository; private readonly IRoomPlayerRepository _roomPlayerRepository = roomPlayerRepository; // TODO: use transactions for long-running tasks private readonly IHandRepository _handRepository = handRepository; private readonly IDeckApiService _deckApiService = deckApiService; private readonly IRoomSSEService _roomSSEService = roomSSEService; + private readonly IUserRepository _userRepository = userRepository; private readonly ILogger _logger = logger; // TODO: add more logging - public async Task GetConfigAsync(Guid gameId) + public string GameMode => GameModes.Blackjack; + + public async Task GetConfigAsync(Guid gameId) { string configString = await _roomRepository.GetGameConfigAsync(gameId); - return JsonSerializer.Deserialize(configString)!; + + if (string.IsNullOrWhiteSpace(configString)) + return new BlackjackConfig(); + + return JsonSerializer.Deserialize(configString) ?? new BlackjackConfig(); } - public async Task SetConfigAsync(Guid gameId, BlackjackConfig config) + public async Task SetConfigAsync(Guid gameId, GameConfig inputConfig) { + if (inputConfig is not BlackjackConfig config) + throw new ArgumentException("Config must be of type BlackjackConfig."); + string configString = JsonSerializer.Serialize(config); await _roomRepository.UpdateGameConfigAsync(gameId, configString); } - public async Task GetGameStateAsync(Guid roomId) + public async Task GetGameStateAsync(Guid roomId) { string stateString = await _roomRepository.GetGameStateAsync(roomId); - return JsonSerializer.Deserialize(stateString)!; + return JsonSerializer.Deserialize(stateString) + ?? throw new InternalServerException("Failed to deserialize game state."); } public static bool IsActionValid(string action, BlackjackStage stage) => @@ -117,8 +130,11 @@ public static bool IsActionValid(string action, BlackjackStage stage) => _ => false, }; - public async Task StartGameAsync(Guid roomId, BlackjackConfig config) + public async Task StartGameAsync(Guid roomId, GameConfig inputConfig) { + if (inputConfig is not BlackjackConfig config) + throw new ArgumentException("Config must be of type BlackjackConfig."); + // create initial game state BlackjackState initialState = new() { @@ -159,6 +175,127 @@ await _roomRepository.GetByIdAsync(roomId) await initialState.SaveStateAndBroadcastAsync(roomId, roomSSEService: _roomSSEService); } + public async Task PlayerJoinAsync(Guid roomId, Guid playerId) + { + if (await GetConfigAsync(roomId) is not BlackjackConfig config) + throw new InternalServerException("Failed to get game config."); + + // validate max player count + int playerCount = await _roomPlayerRepository.GetPlayerCountInRoomAsync(roomId); + if (playerCount >= config.MaxPlayers) + { + throw new BadRequestException( + $"Room {roomId} is full ({playerCount}/{config.MaxPlayers})." + ); + } + + // get user (for name) + User player = + await _userRepository.GetByIdAsync(playerId) + ?? throw new NotFoundException($"User {playerId} not found."); + + // check if player is already in room + if ( + await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) + is RoomPlayer existingPlayer + ) + { + // if player is inactive or left, mark as away + if (existingPlayer.Status is Status.Inactive or Status.Left) + { + existingPlayer.Status = Status.Away; + + // if out of chips and balance reset is enabled, set balance to initial amount + if (config.AllowBalanceReset && existingPlayer.Balance <= 0) + { + existingPlayer.Balance = config.StartingBalance; + } + + await _roomPlayerRepository.UpdateAsync(existingPlayer); + } + } + else + { + // player is new, create new room player + await _roomPlayerRepository.CreateAsync( + new RoomPlayer + { + RoomId = roomId, + UserId = playerId, + Balance = config.StartingBalance, + Status = Status.Away, + } + ); + } + + // broadcast player join + await _roomSSEService.BroadcastEventAsync( + roomId, + RoomEventType.PlayerJoin, + new PlayerJoinEventData() { PlayerId = playerId, PlayerName = player.Name } + ); + } + + public async Task PlayerLeaveAsync(Guid gameId, Guid playerId) + { + // mark user as left + RoomPlayer roomPlayer = + await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(gameId, playerId) + ?? throw new BadRequestException($"Player {playerId} not found in room {gameId}."); + + roomPlayer.Status = Status.Left; + await _roomPlayerRepository.UpdateAsync(roomPlayer); + + // get user + User player = + await _userRepository.GetByIdAsync(playerId) + ?? throw new NotFoundException($"User {playerId} not found."); + + // get room + Room room = + await _roomRepository.GetByIdAsync(gameId) + ?? throw new NotFoundException($"Room {gameId} not found."); + + // broadcast player leave + await _roomSSEService.BroadcastEventAsync( + gameId, + RoomEventType.PlayerLeave, + new PlayerLeaveEventData() { PlayerId = playerId, PlayerName = player.Name } + ); + + // if player was host, find a new host + if (room.HostId == playerId) + { + // get list of players that are active or away + List availablePlayers = + [ + .. (await _roomPlayerRepository.GetByRoomIdAsync(gameId)).Where(p => + p.Status == Status.Active || p.Status == Status.Away + ), + ]; + + if (availablePlayers.Count == 0) + { + // no active players, mark room as inactive + room.IsActive = false; + await _roomRepository.UpdateAsync(room); + } + else + { + // otherwise, make the first active player the new host + room.HostId = availablePlayers[0].UserId; + await _roomRepository.UpdateAsync(room); + + // broadcast new host + await _roomSSEService.BroadcastEventAsync( + gameId, + RoomEventType.HostChange, + new HostChangeEventData() { PlayerId = playerId, PlayerName = player.Name } + ); + } + } + } + public async Task PerformActionAsync( Guid roomId, Guid playerId, @@ -167,7 +304,9 @@ JsonElement data ) { // ensure action is valid for this stage - BlackjackState state = await GetGameStateAsync(roomId); + if (await GetGameStateAsync(roomId) is not BlackjackState state) + throw new InternalServerException("Failed to get game state."); + if (!IsActionValid(action, state.CurrentStage)) { throw new BadRequestException( @@ -183,7 +322,8 @@ await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, playerId) BlackjackActionDTO actionDTO = data.ToBlackjackAction(action); // get config - BlackjackConfig config = await GetConfigAsync(roomId); + if (await GetConfigAsync(roomId) is not BlackjackConfig config) + throw new InternalServerException("Failed to get game config."); // do the action :) switch (actionDTO) @@ -406,7 +546,9 @@ private async Task StartRoundAsync( BlackjackConfig? config = null ) { - config ??= await GetConfigAsync(roomId); + config ??= + await GetConfigAsync(roomId) as BlackjackConfig + ?? throw new InternalServerException("Failed to get game config."); // get bets Dictionary bets = ((BlackjackBettingStage)state.CurrentStage).Bets; @@ -764,7 +906,9 @@ private async Task NextHandOrFinishRoundAsync( BlackjackConfig? config = null ) { - config ??= await GetConfigAsync(roomId); + config ??= + await GetConfigAsync(roomId) as BlackjackConfig + ?? throw new InternalServerException("Failed to get game config."); if (state.CurrentStage is not BlackjackPlayerActionStage) { @@ -840,7 +984,9 @@ private async Task FinishRoundAsync( BlackjackConfig? config = null ) { - config ??= await GetConfigAsync(roomId); + config ??= + await GetConfigAsync(roomId) as BlackjackConfig + ?? throw new InternalServerException("Failed to get game config."); // transition to finish round stage state.CurrentStage = new BlackjackFinishRoundStage(); diff --git a/Project.App/Project.Api/Services/Interface/IGameService.cs b/Project.App/Project.Api/Services/Interface/IGameService.cs index cd6939d..8b0e262 100644 --- a/Project.App/Project.Api/Services/Interface/IGameService.cs +++ b/Project.App/Project.Api/Services/Interface/IGameService.cs @@ -12,6 +12,11 @@ public interface IGameService where TState : IGameState where TConfig : GameConfig { + /// + /// The unique identifier for the game mode handled by the service. + /// + string GameMode { get; } + Task GetConfigAsync(Guid gameId); Task SetConfigAsync(Guid gameId, TConfig config); @@ -23,6 +28,18 @@ public interface IGameService /// Task StartGameAsync(Guid gameId, TConfig config); + /// + /// Adds a player to the game. + /// + Task PlayerJoinAsync(Guid gameId, Guid playerId); + + /// + /// Removes a player from the game. + /// If the host leaves, a new host is selected. + /// If the last player leaves, the game is marked as inactive. + /// + Task PlayerLeaveAsync(Guid gameId, Guid playerId); + /// /// Performs a user action on the game, if valid, then updates the game state. /// Each game implementation provides their own main loop logic implementation, instead of being restricted to a diff --git a/Project.App/Project.Api/Services/RoomService.cs b/Project.App/Project.Api/Services/RoomService.cs index 30b6867..1be548f 100644 --- a/Project.App/Project.Api/Services/RoomService.cs +++ b/Project.App/Project.Api/Services/RoomService.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Project.Api.Data; using Project.Api.DTOs; using Project.Api.Models; @@ -8,26 +7,34 @@ using Project.Api.Services.Interface; using Project.Api.Utilities; using Project.Api.Utilities.Constants; -using Project.Api.Utilities.Enums; namespace Project.Api.Services; public class RoomService( IRoomRepository roomRepository, IRoomPlayerRepository roomPlayerRepository, - IBlackjackService blackjackService, - IDeckApiService deckApiService, AppDbContext dbContext, + IEnumerable> gameServices, ILogger logger ) : IRoomService { private readonly IRoomRepository _roomRepository = roomRepository; private readonly IRoomPlayerRepository _roomPlayerRepository = roomPlayerRepository; - private readonly IBlackjackService _blackjackService = blackjackService; - private readonly IDeckApiService _deckApiService = deckApiService; private readonly AppDbContext _dbContext = dbContext; + private readonly Dictionary> _gameServices = + gameServices.ToDictionary(s => s.GameMode.ToLowerInvariant(), s => s); // Initialize dictionary private readonly ILogger _logger = logger; + // Helper method to get the correct game service + private IGameService GetGameService(string gameMode) + { + if (!_gameServices.TryGetValue(gameMode.ToLowerInvariant(), out var service)) + { + throw new BadRequestException($"Unsupported game mode: {gameMode}"); + } + return service; + } + public async Task GetRoomByIdAsync(Guid id) { var room = await _roomRepository.GetByIdAsync(id); @@ -64,15 +71,6 @@ public async Task CreateRoomAsync(CreateRoomDTO dto) Validate(dto); - // // Auto-create deck if DeckId is not provided - // string deckId = dto.DeckId; - // if (string.IsNullOrWhiteSpace(deckId)) - // { - // _logger.LogInformation("Creating new deck for room"); - // deckId = await _deckApiService.CreateDeck(numOfDecks: 6, enableJokers: false); - // _logger.LogInformation("Created deck with ID: {DeckId}", deckId); - // } - Room room = new() { Id = Guid.CreateVersion7(), @@ -89,19 +87,17 @@ public async Task CreateRoomAsync(CreateRoomDTO dto) IsActive = true, }; - RoomPlayer hostPlayer = new() - { - Id = Guid.CreateVersion7(), - RoomId = room.Id, - UserId = dto.HostId, - Balance = 1000, - }; + // Get the game service for the room's game mode + var gameService = GetGameService(room.GameMode); await using var transaction = await _dbContext.Database.BeginTransactionAsync(); try { Room createdRoom = await _roomRepository.CreateAsync(room); // create room - await _roomPlayerRepository.CreateAsync(hostPlayer); // add host as player + + // Delegate host player creation to the game service + await gameService.PlayerJoinAsync(createdRoom.Id, dto.HostId); + await transaction.CommitAsync(); _logger.LogInformation("Successfully created room with ID: {RoomId}", createdRoom.Id); @@ -209,53 +205,77 @@ await _roomRepository.GetByIdAsync(roomId) ); } - // Initialize game state based on game mode - switch (room.GameMode.ToLower()) + // get game service + var gameService = GetGameService(room.GameMode); + + // determine config based on game mode using a switch statement + GameConfig config; + try { - case GameModes.Blackjack: - BlackjackConfig config; - - // Apply custom config if provided - if (!string.IsNullOrWhiteSpace(gameConfigJson)) - { - config = - JsonSerializer.Deserialize(gameConfigJson) - ?? throw new BadRequestException("Invalid game configuration JSON."); - GameConfigValidator.ValidateBlackjackConfig(config); - _logger.LogInformation( - "Using custom config for room {RoomId}: StartingBalance={Balance}, MinBet={MinBet}", - roomId, - config.StartingBalance, - config.MinBet + switch (room.GameMode) + { + case GameModes.Blackjack: + if (!string.IsNullOrWhiteSpace(gameConfigJson)) + { + // Deserialize to specific BlackjackConfig type + config = + JsonSerializer.Deserialize(gameConfigJson) + ?? throw new BadRequestException( + "Invalid Blackjack game configuration JSON." + ); + _logger.LogInformation( + "Using custom Blackjack config for room {RoomId}", + roomId + ); + } + else if (!string.IsNullOrWhiteSpace(room.GameConfig)) + { + // Use existing config for Blackjack + config = + JsonSerializer.Deserialize(room.GameConfig) + ?? throw new BadRequestException( + "Invalid existing Blackjack game configuration." + ); + _logger.LogInformation( + "Using existing Blackjack config for room {RoomId}", + roomId + ); + } + else + { + // Use default Blackjack config + config = new BlackjackConfig(); + _logger.LogInformation( + "Using default Blackjack config for room {RoomId}", + roomId + ); + } + break; + // Add cases for other game modes here if they have specific GameConfig types + // case GameModes.Poker: + // // ... handle PokerConfig ... + // break; + default: + // Fallback for unsupported game modes or if a specific config type isn't handled + // This should ideally be caught by GetGameService, but as a safeguard: + throw new BadRequestException( + $"Unsupported game mode for configuration: {room.GameMode}" ); - } - else if (!string.IsNullOrWhiteSpace(room.GameConfig)) - { - config = - JsonSerializer.Deserialize(room.GameConfig) - ?? throw new BadRequestException("Invalid existing game configuration."); - GameConfigValidator.ValidateBlackjackConfig(config); - _logger.LogInformation("Using existing config for room {RoomId}", roomId); - } - else - { - // Use default config - config = new BlackjackConfig(); - _logger.LogInformation("Using default config for room {RoomId}", roomId); - } - - await _blackjackService.StartGameAsync(roomId, config); - break; - - default: - _logger.LogError( - "Unsupported game mode '{GameMode}' for room {RoomId}", - room.GameMode, - roomId - ); - throw new BadRequestException($"Unsupported game mode: {room.GameMode}"); + } + } + catch (JsonException ex) + { + _logger.LogError( + ex, + "Failed to deserialize game configuration for room {RoomId}", + roomId + ); + throw new BadRequestException("Invalid game configuration format."); } + // start the game! + await gameService.StartGameAsync(roomId, config); // delegate setup to the generic game service + _logger.LogInformation("Successfully started game for room {RoomId}", roomId); return MapToResponseDto( await _roomRepository.GetByIdAsync(roomId) @@ -306,26 +326,14 @@ await _roomRepository.GetByIdAsync(roomId) } // Delegate to the appropriate game service based on game mode - switch (room.GameMode.ToLower()) - { - case GameModes.Blackjack: - await _blackjackService.PerformActionAsync(roomId, playerId, action, data); - _logger.LogInformation( - "Successfully performed action '{Action}' for player {PlayerId} in room {RoomId}", - action, - playerId, - roomId - ); - break; - - default: - _logger.LogError( - "Unsupported game mode '{GameMode}' for room {RoomId}", - room.GameMode, - roomId - ); - throw new BadRequestException($"Unsupported game mode: {room.GameMode}"); - } + var gameService = GetGameService(room.GameMode); + await gameService.PerformActionAsync(roomId, playerId, action, data); + _logger.LogInformation( + "Successfully performed action '{Action}' for player {PlayerId} in room {RoomId}", + action, + playerId, + roomId + ); } // --- player management --- @@ -350,59 +358,10 @@ await _roomRepository.GetByIdAsync(roomId) throw new BadRequestException("Room is not active."); } - // Check if player is already in the room - bool isAlreadyInRoom = await _roomPlayerRepository.IsPlayerInRoomAsync(roomId, userId); - if (isAlreadyInRoom) - { - _logger.LogWarning("User {UserId} is already in room {RoomId}", userId, roomId); - throw new ConflictException($"Player {userId} is already in room {roomId}."); - } - - // Check if room is full - int currentPlayerCount = await _roomPlayerRepository.GetPlayerCountInRoomAsync(roomId); - if (currentPlayerCount >= room.MaxPlayers) - { - _logger.LogWarning( - "Room {RoomId} is full ({CurrentCount}/{MaxPlayers}). User {UserId} cannot join", - roomId, - currentPlayerCount, - room.MaxPlayers, - userId - ); - throw new ConflictException("Room is full."); - } - - // Create new RoomPlayer - var roomPlayer = new RoomPlayer - { - Id = Guid.NewGuid(), - RoomId = roomId, - UserId = userId, - Role = Role.Player, - Status = room.StartedAt == null ? Status.Active : Status.Away, - Balance = 0, // Will be set when game starts - }; - - try - { - await _roomPlayerRepository.CreateAsync(roomPlayer); - _logger.LogInformation( - "User {UserId} successfully joined room {RoomId}", - userId, - roomId - ); - } - catch (DbUpdateException ex) - when (ex.InnerException?.Message.Contains("IX_RoomPlayer_RoomId_UserId_Unique") == true) - { - // Handle race condition where two requests tried to join simultaneously - _logger.LogWarning( - "Race condition detected: User {UserId} attempted duplicate join to room {RoomId}", - userId, - roomId - ); - throw new ConflictException($"Player {userId} is already in room {roomId}."); - } + // delegate to the appropriate game service + // should include setting up the player, adding them to the room, etc. + var gameService = GetGameService(room.GameMode); + await gameService.PlayerJoinAsync(roomId, userId); // Return the room (no need to fetch again, we have it) return MapToResponseDto(room); @@ -417,61 +376,10 @@ public async Task LeaveRoomAsync(Guid roomId, Guid userId) await _roomRepository.GetByIdAsync(roomId) ?? throw new NotFoundException($"Room with ID {roomId} not found."); - // Get the player in the room - var roomPlayer = - await _roomPlayerRepository.GetByRoomIdAndUserIdAsync(roomId, userId) - ?? throw new NotFoundException($"Player {userId} is not in room {roomId}."); - - // Check if player is the host - if (room.HostId == userId) - { - _logger.LogInformation( - "Host {UserId} leaving room {RoomId}. Closing room and removing all players", - userId, - roomId - ); - - // Host is leaving - close the room within a transaction - await using var transaction = await _dbContext.Database.BeginTransactionAsync(); - try - { - // Mark room as inactive - room.IsActive = false; - room.EndedAt = DateTime.UtcNow; - await _roomRepository.UpdateAsync(room); - - // Remove all players - var allPlayers = await _roomPlayerRepository.GetByRoomIdAsync(roomId); - foreach (var player in allPlayers) - { - await _roomPlayerRepository.DeleteAsync(player.Id); - } - - await transaction.CommitAsync(); - _logger.LogInformation( - "Successfully closed room {RoomId} and removed {PlayerCount} players", - roomId, - allPlayers.Count() - ); - } - catch (Exception ex) - { - await transaction.RollbackAsync(); - _logger.LogError( - ex, - "Failed to close room {RoomId} when host {UserId} left", - roomId, - userId - ); - throw; - } - } - else - { - // Regular player leaving - just remove them - _logger.LogInformation("Player {UserId} leaving room {RoomId}", userId, roomId); - await _roomPlayerRepository.DeleteAsync(roomPlayer.Id); - } + // delegate to the appropriate game service + // should include removing the player from the room, selecting new host if necessary, etc. + var gameService = GetGameService(room.GameMode); + await gameService.PlayerLeaveAsync(roomId, userId); // Return the room (we already have it) return MapToResponseDto(room); diff --git a/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs b/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs index bec83bf..c906f20 100644 --- a/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs +++ b/Project.App/Project.Api/Utilities/Enums/RoomEventType.cs @@ -5,8 +5,9 @@ public enum RoomEventType Message, GameStateUpdate, PlayerAction, - PlayerJoined, - PlayerLeft, + PlayerJoin, + PlayerLeave, + HostChange, DealerReveal, PlayerReveal, } diff --git a/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs b/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs index 1ccf3de..6e0f95d 100644 --- a/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs +++ b/Project.App/Project.Test/Integration/BlackjackIntegrationTests.cs @@ -226,6 +226,22 @@ CancellationTokenSource cts joinRoomResponse.EnsureSuccessStatusCode(); } + // ASSERT: PlayerJoin events for player1 and player2 are received by all clients. + // The host (player0) will not receive their own PlayerJoin event because they created the room + // before connecting to SSE. + // Each of the 3 players' readers will receive 2 PlayerJoin events (for player1 and player2). + foreach (var (_, _, reader, _) in players) + { + for (int i = 1; i < players.Count; i++) // Loop for player1 and player2 + { + var (eventType, eventData) = await SseTestHelper.ReadSseEventAsync(reader); + eventType.Should().Be(RoomEventType.PlayerJoin); + var playerJoinEvent = SseTestHelper.Deserialize(eventData); + playerJoinEvent!.PlayerId.Should().Be(players[i].id); + playerJoinEvent.PlayerName.Should().StartWith("Player"); // "Player1", "Player2" + } + } + // --- STARTING THE GAME --- var startGameResponse = await client0.PostAsJsonAsync( $"/api/room/{roomId}/start", @@ -780,6 +796,22 @@ CancellationTokenSource cts joinRoomResponse.EnsureSuccessStatusCode(); } + // ASSERT: PlayerJoin events for player1 and player2 are received by all clients. + // The host (player0) will not receive their own PlayerJoin event because they created the room + // before connecting to SSE. + // Each of the 3 players' readers will receive 2 PlayerJoin events (for player1 and player2). + foreach (var (_, _, reader, _) in players) + { + for (int i = 1; i < players.Count; i++) // Loop for player1 and player2 + { + var (eventType, eventData) = await SseTestHelper.ReadSseEventAsync(reader); + eventType.Should().Be(RoomEventType.PlayerJoin); + var playerJoinEvent = SseTestHelper.Deserialize(eventData); + playerJoinEvent!.PlayerId.Should().Be(players[i].id); + playerJoinEvent.PlayerName.Should().StartWith("Player"); // "Player1", "Player2" + } + } + // --- STARTING THE GAME --- var startGameResponse = await client0.PostAsJsonAsync( $"/api/room/{roomId}/start", diff --git a/Project.App/Project.Test/Services/BlackjackServiceTest.cs b/Project.App/Project.Test/Services/BlackjackServiceTest.cs index 76be717..840b317 100644 --- a/Project.App/Project.Test/Services/BlackjackServiceTest.cs +++ b/Project.App/Project.Test/Services/BlackjackServiceTest.cs @@ -20,6 +20,7 @@ public class BlackjackServiceTest private readonly Mock _handRepositoryMock; private readonly Mock _deckApiServiceMock; private readonly Mock _roomSSEServiceMock; + private readonly Mock _userRepositoryMock; private readonly BlackjackService _blackjackService; public BlackjackServiceTest() @@ -29,6 +30,7 @@ public BlackjackServiceTest() _handRepositoryMock = new Mock(); _deckApiServiceMock = new Mock(); _roomSSEServiceMock = new Mock(); + _userRepositoryMock = new Mock(); var defaultConfig = new BlackjackConfig(); var defaultConfigString = JsonSerializer.Serialize(defaultConfig); @@ -42,6 +44,7 @@ public BlackjackServiceTest() _handRepositoryMock.Object, _deckApiServiceMock.Object, _roomSSEServiceMock.Object, + _userRepositoryMock.Object, new Mock>().Object ); } diff --git a/Project.App/Project.Test/Services/RoomSSEServiceTests.cs b/Project.App/Project.Test/Services/RoomSSEServiceTests.cs index cd9c422..df27c23 100644 --- a/Project.App/Project.Test/Services/RoomSSEServiceTests.cs +++ b/Project.App/Project.Test/Services/RoomSSEServiceTests.cs @@ -76,7 +76,7 @@ public async Task BroadcastEventAsync_ShouldSendEventToAllClientsInRoom() await Task.Delay(100); // Use the new RoomEventType enum and PlayerJoinEventData DTO - var eventType = RoomEventType.PlayerJoined; + var eventType = RoomEventType.PlayerJoin; var eventData = new PlayerJoinEventData { PlayerId = Guid.NewGuid(), diff --git a/Project.App/Project.Test/Services/RoomServiceTest.cs b/Project.App/Project.Test/Services/RoomServiceTest.cs index 68e806f..bedd06e 100644 --- a/Project.App/Project.Test/Services/RoomServiceTest.cs +++ b/Project.App/Project.Test/Services/RoomServiceTest.cs @@ -14,25 +14,27 @@ using Project.Api.Utilities.Constants; using Project.Api.Utilities.Enums; using Project.Test.Helpers; +using Xunit; namespace Project.Test.Services; -public class RoomServiceTest +public class RoomServiceTest : IDisposable // Implement IDisposable for DbContext cleanup { private readonly Mock _roomRepositoryMock; private readonly Mock _roomPlayerRepositoryMock; - private readonly Mock _blackjackServiceMock; - private readonly Mock _deckApiServiceMock; + + // Change this to mock the generic interface directly + private readonly Mock> _mockBlackjackGameService; + private readonly List> _mockGameServices; // Collection for RoomService private readonly Mock> _loggerMock; - private readonly AppDbContext _dbContext; + private readonly AppDbContext _dbContext; // Real in-memory DbContext for transaction tests private readonly RoomService _roomService; public RoomServiceTest() { _roomRepositoryMock = new Mock(); _roomPlayerRepositoryMock = new Mock(); - _blackjackServiceMock = new Mock(); - _deckApiServiceMock = new Mock(); + _loggerMock = new Mock>(); // Configure DbContext to ignore transaction warnings for in-memory provider var options = new DbContextOptionsBuilder() @@ -49,18 +51,58 @@ public RoomServiceTest() .Options; _dbContext = new AppDbContext(options); - _loggerMock = new Mock>(); + // Setup the generic IGameService mock to represent Blackjack + _mockBlackjackGameService = new Mock>(); + _mockBlackjackGameService.Setup(s => s.GameMode).Returns(GameModes.Blackjack); + _mockBlackjackGameService + .Setup(s => s.PlayerJoinAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _mockBlackjackGameService + .Setup(s => s.PlayerLeaveAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _mockBlackjackGameService + .Setup(s => + s.PerformActionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .Returns(Task.CompletedTask); + _mockBlackjackGameService + .Setup(s => s.StartGameAsync(It.IsAny(), It.IsAny())) // Use generic GameConfig here + .Returns(Task.CompletedTask); + _mockBlackjackGameService + .Setup(s => s.GetConfigAsync(It.IsAny())) + .ReturnsAsync(new BlackjackConfig()); // Return a concrete GameConfig type + _mockBlackjackGameService + .Setup(s => s.GetGameStateAsync(It.IsAny())) + .ReturnsAsync(new BlackjackState { CurrentStage = new BlackjackInitStage() }); // Return a concrete IGameState type + + // Initialize the list of game services and add the generic blackjack mock + _mockGameServices = new List> + { + _mockBlackjackGameService.Object, + }; + // Instantiate RoomService with the updated constructor _roomService = new RoomService( _roomRepositoryMock.Object, _roomPlayerRepositoryMock.Object, - _blackjackServiceMock.Object, - _deckApiServiceMock.Object, - _dbContext, - new Mock>().Object + _dbContext, // Pass the real in-memory DbContext + _mockGameServices, // Pass the collection of game services + _loggerMock.Object ); } + // Ensure the DbContext is clean for each test that uses it directly + public void Dispose() + { + _dbContext.Database.EnsureDeleted(); + _dbContext.Dispose(); + } + #region GetRoomByIdAsync Tests [Fact] @@ -119,6 +161,20 @@ public async Task GetAllRoomsAsync_ReturnsAllRooms_WhenSuccessful() _roomRepositoryMock.Verify(r => r.GetAllAsync(), Times.Once); } + [Fact] + public async Task GetAllRoomsAsync_ReturnsEmptyList_WhenNoRoomsExist() + { + // Arrange + _roomRepositoryMock.Setup(r => r.GetAllAsync()).ReturnsAsync(new List()); + + // Act + var result = await _roomService.GetAllRoomsAsync(); + + // Assert + result.Should().BeEmpty(); + _roomRepositoryMock.Verify(r => r.GetAllAsync(), Times.Once); + } + #endregion #region GetActiveRoomsAsync Tests @@ -143,6 +199,20 @@ public async Task GetActiveRoomsAsync_ReturnsActiveRooms_WhenSuccessful() _roomRepositoryMock.Verify(r => r.GetActiveRoomsAsync(), Times.Once); } + [Fact] + public async Task GetActiveRoomsAsync_ReturnsEmptyList_WhenNoActiveRoomsExist() + { + // Arrange + _roomRepositoryMock.Setup(r => r.GetActiveRoomsAsync()).ReturnsAsync(new List()); + + // Act + var result = await _roomService.GetActiveRoomsAsync(); + + // Assert + result.Should().BeEmpty(); + _roomRepositoryMock.Verify(r => r.GetActiveRoomsAsync(), Times.Once); + } + #endregion #region GetPublicRoomsAsync Tests @@ -167,6 +237,20 @@ public async Task GetPublicRoomsAsync_ReturnsPublicRooms_WhenSuccessful() _roomRepositoryMock.Verify(r => r.GetPublicRoomsAsync(), Times.Once); } + [Fact] + public async Task GetPublicRoomsAsync_ReturnsEmptyList_WhenNoPublicRoomsExist() + { + // Arrange + _roomRepositoryMock.Setup(r => r.GetPublicRoomsAsync()).ReturnsAsync(new List()); + + // Act + var result = await _roomService.GetPublicRoomsAsync(); + + // Assert + result.Should().BeEmpty(); + _roomRepositoryMock.Verify(r => r.GetPublicRoomsAsync(), Times.Once); + } + #endregion #region GetRoomByHostIdAsync Tests @@ -207,87 +291,81 @@ public async Task GetRoomByHostIdAsync_ReturnsNull_WhenRoomDoesNotExist() #region CreateRoomAsync Tests - // deck id is not set on room creation anymore, so this test is obsolete - // [Fact] - // public async Task CreateRoomAsync_CreatesRoom_WithProvidedDeckId() - // { - // // Arrange - // var hostId = Guid.NewGuid(); - // var deckId = "test-deck-123"; - // var createDto = new CreateRoomDTO - // { - // HostId = hostId, - // IsPublic = true, - // GameMode = GameModes.Blackjack, - // GameState = "{}", - // GameConfig = "{}", - // Description = "Test Room", - // MaxPlayers = 6, - // MinPlayers = 2, - // }; - - // var createdRoom = RepositoryTestHelper.CreateTestRoom( - // hostId: hostId, - // gameMode: GameModes.Blackjack - // ); - // _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(createdRoom); - - // // Act - // var result = await _roomService.CreateRoomAsync(createDto); - - // // Assert - // result.Should().NotBeNull(); - // result.HostId.Should().Be(hostId); - // _roomRepositoryMock.Verify( - // r => r.CreateAsync(It.Is(room => room.HostId == hostId && room.DeckId == deckId)), - // Times.Once - // ); - // _deckApiServiceMock.Verify( - // d => d.CreateDeck(It.IsAny(), It.IsAny()), - // Times.Never - // ); - // } - - // deck id is not set on room creation anymore, so this test is obsolete - // [Fact] - // public async Task CreateRoomAsync_CreatesRoom_WithAutoDeckCreation_WhenDeckIdNotProvided() - // { - // // Arrange - // var hostId = Guid.NewGuid(); - // var autoDeckId = "auto-created-deck-456"; - // var createDto = new CreateRoomDTO - // { - // HostId = hostId, - // IsPublic = true, - // GameMode = GameModes.Blackjack, - // GameState = "{}", - // GameConfig = "{}", - // Description = "Test Room", - // MaxPlayers = 6, - // MinPlayers = 2, - // }; - - // _deckApiServiceMock.Setup(d => d.CreateDeck(6, false)).ReturnsAsync(autoDeckId); - - // var createdRoom = RepositoryTestHelper.CreateTestRoom( - // hostId: hostId, - // gameMode: GameModes.Blackjack - // ); - // createdRoom.DeckId = autoDeckId; - // _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(createdRoom); - - // // Act - // var result = await _roomService.CreateRoomAsync(createDto); - - // // Assert - // result.Should().NotBeNull(); - // result.HostId.Should().Be(hostId); - // _deckApiServiceMock.Verify(d => d.CreateDeck(6, false), Times.Once); - // _roomRepositoryMock.Verify( - // r => r.CreateAsync(It.Is(room => room.DeckId == autoDeckId)), - // Times.Once - // ); - // } + [Fact] + public async Task CreateRoomAsync_CreatesRoomAndHostPlayer_WhenDtoIsValid() + { + // Arrange + var createDto = new CreateRoomDTO + { + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = GameModes.Blackjack, + GameConfig = "{}", + Description = "A valid test room", + MaxPlayers = 4, + MinPlayers = 2, + }; + + // Capture the Room instance created by the service + Room? capturedRoom = null; + _roomRepositoryMock + .Setup(r => r.CreateAsync(It.IsAny())) + .Callback(room => capturedRoom = room) // Capture the room + .ReturnsAsync((Room room) => room); // Return the captured room + + // Act + var result = await _roomService.CreateRoomAsync(createDto); + + // Assert + result.Should().NotBeNull(); + capturedRoom.Should().NotBeNull(); + // capturedRoomPlayer.Should().NotBeNull(); // REMOVE THIS ASSERTION + + result.Id.Should().Be(capturedRoom!.Id); + result.HostId.Should().Be(createDto.HostId); + + _roomRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Once); + // Verify that RoomService *delegated* player creation to the game service + _mockBlackjackGameService.Verify( + s => s.PlayerJoinAsync(capturedRoom.Id, createDto.HostId), + Times.Once + ); + // Verify that RoomService itself *did not* call _roomPlayerRepository.CreateAsync + _roomPlayerRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + + // Verify properties of the captured room + capturedRoom!.HostId.Should().Be(createDto.HostId); + capturedRoom.GameMode.Should().Be(createDto.GameMode); + capturedRoom.IsActive.Should().BeTrue(); + capturedRoom.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task CreateRoomAsync_RollsBackTransaction_OnFailure() + { + // Arrange + var createDto = new CreateRoomDTO + { + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = GameModes.Blackjack, + GameConfig = "{}", + Description = "A valid test room", + MaxPlayers = 4, + MinPlayers = 2, + }; + + _roomRepositoryMock + .Setup(r => r.CreateAsync(It.IsAny())) + .ThrowsAsync(new Exception("Database error")); // Simulate failure + + // Act & Assert + await Assert.ThrowsAsync(() => _roomService.CreateRoomAsync(createDto)); + + _roomRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Once); + _roomPlayerRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); // Should not be called + // With in-memory DB and mocks, verifying rollback directly is hard, but exception propagation implies it. + } [Fact] public async Task CreateRoomAsync_ThrowsBadRequestException_WhenMinPlayersLessThanOne() @@ -307,6 +385,7 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenMinPlayersLessTh await Assert.ThrowsAsync(() => _roomService.CreateRoomAsync(createDto) ); + _roomRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); } [Fact] @@ -327,6 +406,7 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenMaxPlayersLessTh await Assert.ThrowsAsync(() => _roomService.CreateRoomAsync(createDto) ); + _roomRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); } [Fact] @@ -347,6 +427,7 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenGameModeIsEmpty( await Assert.ThrowsAsync(() => _roomService.CreateRoomAsync(createDto) ); + _roomRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); } [Fact] @@ -368,6 +449,7 @@ public async Task CreateRoomAsync_ThrowsBadRequestException_WhenDescriptionTooLo await Assert.ThrowsAsync(() => _roomService.CreateRoomAsync(createDto) ); + _roomRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); } #endregion @@ -441,6 +523,99 @@ public async Task UpdateRoomAsync_ReturnsNull_WhenRoomDoesNotExist() _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task UpdateRoomAsync_ThrowsBadRequestException_WhenMinPlayersLessThanOne() + { + // Arrange + var roomId = Guid.NewGuid(); + var updateDto = new UpdateRoomDTO + { + Id = roomId, + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = GameModes.Blackjack, + GameConfig = "{}", + MaxPlayers = 6, + MinPlayers = 0, // Invalid + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.UpdateRoomAsync(updateDto) + ); + _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateRoomAsync_ThrowsBadRequestException_WhenMaxPlayersLessThanMinPlayers() + { + // Arrange + var roomId = Guid.NewGuid(); + var updateDto = new UpdateRoomDTO + { + Id = roomId, + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = GameModes.Blackjack, + GameConfig = "{}", + MaxPlayers = 2, + MinPlayers = 5, // Invalid - greater than MaxPlayers + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.UpdateRoomAsync(updateDto) + ); + _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateRoomAsync_ThrowsBadRequestException_WhenGameModeIsEmpty() + { + // Arrange + var roomId = Guid.NewGuid(); + var updateDto = new UpdateRoomDTO + { + Id = roomId, + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = "", // Invalid + GameConfig = "{}", + MaxPlayers = 6, + MinPlayers = 2, + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.UpdateRoomAsync(updateDto) + ); + _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateRoomAsync_ThrowsBadRequestException_WhenDescriptionTooLong() + { + // Arrange + var roomId = Guid.NewGuid(); + var updateDto = new UpdateRoomDTO + { + Id = roomId, + HostId = Guid.NewGuid(), + IsPublic = true, + GameMode = GameModes.Blackjack, + GameConfig = "{}", + Description = new string('a', 501), // 501 characters - too long + MaxPlayers = 6, + MinPlayers = 2, + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.UpdateRoomAsync(updateDto) + ); + _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + #endregion #region DeleteRoomAsync Tests @@ -561,6 +736,10 @@ public async Task UpdateGameStateAsync_ThrowsBadRequestException_WhenGameStateIs await Assert.ThrowsAsync(() => _roomService.UpdateGameStateAsync(roomId, "") ); + _roomRepositoryMock.Verify( + r => r.UpdateGameStateAsync(It.IsAny(), It.IsAny()), + Times.Never + ); } [Fact] @@ -573,6 +752,10 @@ public async Task UpdateGameStateAsync_ThrowsBadRequestException_WhenGameStateIs await Assert.ThrowsAsync(() => _roomService.UpdateGameStateAsync(roomId, " ") ); + _roomRepositoryMock.Verify( + r => r.UpdateGameStateAsync(It.IsAny(), It.IsAny()), + Times.Never + ); } #endregion @@ -627,6 +810,10 @@ public async Task UpdateGameConfigAsync_ThrowsBadRequestException_WhenGameConfig await Assert.ThrowsAsync(() => _roomService.UpdateGameConfigAsync(roomId, "") ); + _roomRepositoryMock.Verify( + r => r.UpdateGameConfigAsync(It.IsAny(), It.IsAny()), + Times.Never + ); } #endregion @@ -642,31 +829,32 @@ public async Task StartGameAsync_StartsBlackjackGame_WithDefaultConfig() { Id = roomId, GameMode = GameModes.Blackjack, - GameState = "", + GameState = "{}", MinPlayers = 1, - GameConfig = "", + GameConfig = "", // No existing config + StartedAt = null, }; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); - _blackjackServiceMock - .Setup(bs => bs.StartGameAsync(roomId, It.IsAny())) - .Returns(Task.CompletedTask); + _roomRepositoryMock.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(room); // For the return DTO // Act await _roomService.StartGameAsync(roomId, null); // Assert - _blackjackServiceMock.Verify( + _mockBlackjackGameService.Verify( bs => bs.StartGameAsync( roomId, - It.Is(c => - c.StartingBalance == new BlackjackConfig().StartingBalance + It.Is(c => // Verify against generic GameConfig + ((BlackjackConfig)c).StartingBalance + == new BlackjackConfig().StartingBalance ) ), Times.Once ); + _roomRepositoryMock.Verify(r => r.GetByIdAsync(roomId), Times.Exactly(2)); // Once for validation, once for return DTO } [Fact] @@ -677,31 +865,73 @@ public async Task StartGameAsync_StartsBlackjackGame_WithCustomConfig() var room = new Room { Id = roomId, - GameState = "", + GameState = "{}", GameMode = GameModes.Blackjack, MinPlayers = 1, + StartedAt = null, }; - var customConfig = new BlackjackConfig { StartingBalance = 5000 }; + var customConfig = new BlackjackConfig { StartingBalance = 5000, MaxPlayers = 2 }; var customConfigJson = JsonSerializer.Serialize(customConfig); _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); - _blackjackServiceMock - .Setup(bs => bs.StartGameAsync(roomId, It.IsAny())) - .Returns(Task.CompletedTask); + _roomRepositoryMock.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(room); // For the return DTO // Act await _roomService.StartGameAsync(roomId, customConfigJson); // Assert - _blackjackServiceMock.Verify( + _mockBlackjackGameService.Verify( + bs => + bs.StartGameAsync( + roomId, + It.Is(c => // Verify against generic GameConfig + ((BlackjackConfig)c).StartingBalance == customConfig.StartingBalance + && ((BlackjackConfig)c).MaxPlayers == customConfig.MaxPlayers + ) + ), + Times.Once + ); + _roomRepositoryMock.Verify(r => r.GetByIdAsync(roomId), Times.Exactly(2)); + } + + [Fact] + public async Task StartGameAsync_StartsBlackjackGame_WithExistingConfigInRoom() + { + // Arrange + var roomId = Guid.NewGuid(); + var existingConfig = new BlackjackConfig { StartingBalance = 7500, MaxPlayers = 3 }; + var existingConfigJson = JsonSerializer.Serialize(existingConfig); + var room = new Room + { + Id = roomId, + GameState = "{}", + GameMode = GameModes.Blackjack, + MinPlayers = 1, + GameConfig = existingConfigJson, // Existing config in room + StartedAt = null, + }; + + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); + _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); + _roomRepositoryMock.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(room); // For the return DTO + + // Act + await _roomService.StartGameAsync(roomId, null); // No custom config provided + + // Assert + _mockBlackjackGameService.Verify( bs => bs.StartGameAsync( roomId, - It.Is(c => c.StartingBalance == customConfig.StartingBalance) + It.Is(c => // Verify against generic GameConfig + ((BlackjackConfig)c).StartingBalance == existingConfig.StartingBalance + && ((BlackjackConfig)c).MaxPlayers == existingConfig.MaxPlayers + ) ), Times.Once ); + _roomRepositoryMock.Verify(r => r.GetByIdAsync(roomId), Times.Exactly(2)); } [Fact] @@ -713,6 +943,10 @@ public async Task StartGameAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() // Act & Assert await Assert.ThrowsAsync(() => _roomService.StartGameAsync(roomId)); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never + ); } [Fact] @@ -727,6 +961,10 @@ public async Task StartGameAsync_ThrowsBadRequestException_WhenGameAlreadyStarte // Act & Assert await Assert.ThrowsAsync(() => _roomService.StartGameAsync(roomId)); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never + ); } [Fact] @@ -745,6 +983,10 @@ public async Task StartGameAsync_ThrowsBadRequestException_WhenNotEnoughPlayers( _roomService.StartGameAsync(roomId) ); exception.Message.Should().Contain("Minimum 3 players required"); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never + ); } [Fact] @@ -754,7 +996,7 @@ public async Task StartGameAsync_ThrowsBadRequestException_WhenUnsupportedGameMo var roomId = Guid.NewGuid(); var room = RepositoryTestHelper.CreateTestRoom( id: roomId, - gameMode: "UnsupportedGame", + gameMode: "UnsupportedGame", // This game mode is not in _mockGameServices minPlayers: 2 ); room.StartedAt = null; @@ -764,317 +1006,295 @@ public async Task StartGameAsync_ThrowsBadRequestException_WhenUnsupportedGameMo // Act & Assert await Assert.ThrowsAsync(() => _roomService.StartGameAsync(roomId)); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never + ); } - // deck id is not set on room creation anymore, so this test is obsolete - // [Fact] - // public async Task StartGameAsync_ThrowsInternalServerException_WhenDeckIdIsNull() - // { - // // Arrange - // var roomId = Guid.NewGuid(); - // var room = RepositoryTestHelper.CreateTestRoom( - // id: roomId, - // gameMode: GameModes.Blackjack, - // minPlayers: 2 - // ); - // room.StartedAt = null; - // room.DeckId = null; // Null DeckId should cause error - - // var players = new List - // { - // RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId), - // }; - - // _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - // _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - // _roomPlayerRepositoryMock.Setup(r => r.GetByRoomIdAsync(roomId)).ReturnsAsync(players); - // _roomPlayerRepositoryMock - // .Setup(r => r.UpdatePlayersInRoomAsync(roomId, It.IsAny>())) - // .Returns(Task.CompletedTask); - - // // Act & Assert - // await Assert.ThrowsAsync(() => - // _roomService.StartGameAsync(roomId) - // ); - // } - - #endregion - - #region JoinRoomAsync Tests - [Fact] - public async Task JoinRoomAsync_AddsPlayerToRoom_WhenSuccessful() + public async Task StartGameAsync_ThrowsBadRequestException_WhenCustomConfigJsonIsInvalid() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: true, maxPlayers: 6); - room.StartedAt = null; // Game not started + var room = new Room + { + Id = roomId, + GameState = "{}", + GameMode = GameModes.Blackjack, + MinPlayers = 1, + StartedAt = null, + }; + var invalidConfigJson = "{ \"StartingBalance\": \"not-a-number\" }"; // Invalid JSON for BlackjackConfig _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, userId)) - .ReturnsAsync(false); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - _roomPlayerRepositoryMock - .Setup(r => r.CreateAsync(It.IsAny())) - .ReturnsAsync( - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId, userId: userId) - ); - - // Act - var result = await _roomService.JoinRoomAsync(roomId, userId); + _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); - // Assert - result.Should().NotBeNull(); - result.Id.Should().Be(roomId); - _roomPlayerRepositoryMock.Verify( - r => - r.CreateAsync( - It.Is(rp => - rp.RoomId == roomId - && rp.UserId == userId - && rp.Role == Role.Player - && rp.Status == Status.Active - ) - ), - Times.Once + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.StartGameAsync(roomId, invalidConfigJson) + ); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never ); } [Fact] - public async Task JoinRoomAsync_SetsStatusToAway_WhenGameAlreadyStarted() + public async Task StartGameAsync_ThrowsBadRequestException_WhenExistingConfigJsonIsInvalid() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: true, maxPlayers: 6); - room.StartedAt = DateTime.UtcNow.AddMinutes(-5); // Game already started + var room = new Room + { + Id = roomId, + GameState = "{}", + GameMode = GameModes.Blackjack, + MinPlayers = 1, + GameConfig = "{ \"StartingBalance\": \"not-a-number\" }", // Invalid existing config + StartedAt = null, + }; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, userId)) - .ReturnsAsync(false); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - _roomPlayerRepositoryMock - .Setup(r => r.CreateAsync(It.IsAny())) - .ReturnsAsync( - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId, userId: userId) - ); - - // Act - var result = await _roomService.JoinRoomAsync(roomId, userId); + _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); - // Assert - result.Should().NotBeNull(); - _roomPlayerRepositoryMock.Verify( - r => r.CreateAsync(It.Is(rp => rp.Status == Status.Away)), - Times.Once + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.StartGameAsync(roomId, null) + ); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never ); } [Fact] - public async Task JoinRoomAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() + public async Task StartGameAsync_ThrowsBadRequestException_WhenGameConfigTypeCannotBeDetermined() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync((Room?)null); + var room = new Room + { + Id = roomId, + GameState = "{}", + GameMode = GameModes.Blackjack, + MinPlayers = 1, + StartedAt = null, + }; + + // Temporarily clear the game services to simulate no matching service + _mockGameServices.Clear(); + var roomServiceWithoutBlackjack = new RoomService( + _roomRepositoryMock.Object, + _roomPlayerRepositoryMock.Object, + _dbContext, + _mockGameServices, // Empty game services + _loggerMock.Object + ); + + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); + _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(1); // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.JoinRoomAsync(roomId, userId) + await Assert.ThrowsAsync(() => + roomServiceWithoutBlackjack.StartGameAsync(roomId) ); + _mockBlackjackGameService.Verify( + s => s.StartGameAsync(It.IsAny(), It.IsAny()), // Verify against generic GameConfig + Times.Never + ); + + // Restore mock for other tests + _mockGameServices.Add(_mockBlackjackGameService.Object); } + #endregion + + #region PerformPlayerActionAsync Tests + [Fact] - public async Task JoinRoomAsync_ThrowsBadRequestException_WhenRoomIsNotActive() + public async Task PerformPlayerActionAsync_DelegatesToGameService_WhenValid() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: false); + var playerId = Guid.NewGuid(); + var action = "hit"; + var data = JsonDocument.Parse("{\"amount\":100}").RootElement; + + var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: GameModes.Blackjack); + room.StartedAt = DateTime.UtcNow.AddMinutes(-5); _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); + _roomPlayerRepositoryMock + .Setup(r => r.IsPlayerInRoomAsync(roomId, playerId)) + .ReturnsAsync(true); - // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.JoinRoomAsync(roomId, userId) + // Act + await _roomService.PerformPlayerActionAsync(roomId, playerId, action, data); + + // Assert + _mockBlackjackGameService.Verify( + s => s.PerformActionAsync(roomId, playerId, action, data), + Times.Once ); } [Fact] - public async Task JoinRoomAsync_ThrowsConflictException_WhenPlayerAlreadyInRoom() + public async Task PerformPlayerActionAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: true); + var playerId = Guid.NewGuid(); + var data = JsonDocument.Parse("{}").RootElement; - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, userId)) - .ReturnsAsync(true); + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync((Room?)null); // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.JoinRoomAsync(roomId, userId) + await Assert.ThrowsAsync(() => + _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) + ); + _mockBlackjackGameService.Verify( + s => + s.PerformActionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never ); } [Fact] - public async Task JoinRoomAsync_ThrowsConflictException_WhenRoomIsFull() + public async Task PerformPlayerActionAsync_ThrowsBadRequestException_WhenGameNotStarted() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: true, maxPlayers: 4); + var playerId = Guid.NewGuid(); + var data = JsonDocument.Parse("{}").RootElement; + var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: GameModes.Blackjack); + room.StartedAt = null; // Game not started _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, userId)) - .ReturnsAsync(false); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(4); // Room is full // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _roomService.JoinRoomAsync(roomId, userId) + await Assert.ThrowsAsync(() => + _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) + ); + _mockBlackjackGameService.Verify( + s => + s.PerformActionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never ); - exception.Message.Should().Contain("Room is full"); } [Fact] - public async Task JoinRoomAsync_ThrowsConflictException_OnRaceCondition() + public async Task PerformPlayerActionAsync_ThrowsBadRequestException_WhenPlayerNotInRoom() { // Arrange var roomId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: true); + var playerId = Guid.NewGuid(); + var data = JsonDocument.Parse("{}").RootElement; + var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: GameModes.Blackjack); + room.StartedAt = DateTime.UtcNow; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, userId)) + .Setup(r => r.IsPlayerInRoomAsync(roomId, playerId)) .ReturnsAsync(false); - _roomPlayerRepositoryMock.Setup(r => r.GetPlayerCountInRoomAsync(roomId)).ReturnsAsync(2); - - // Simulate race condition with duplicate key exception - var innerException = new Exception("IX_RoomPlayer_RoomId_UserId_Unique"); - var dbException = new DbUpdateException("Duplicate entry", innerException); - _roomPlayerRepositoryMock - .Setup(r => r.CreateAsync(It.IsAny())) - .ThrowsAsync(dbException); // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.JoinRoomAsync(roomId, userId) + await Assert.ThrowsAsync(() => + _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) + ); + _mockBlackjackGameService.Verify( + s => + s.PerformActionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never ); } - #endregion - - #region LeaveRoomAsync Tests - [Fact] - public async Task LeaveRoomAsync_RemovesPlayer_WhenPlayerIsNotHost() + public async Task PerformPlayerActionAsync_ThrowsBadRequestException_WhenUnsupportedGameMode() { // Arrange var roomId = Guid.NewGuid(); - var hostId = Guid.NewGuid(); var playerId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, hostId: hostId); - var roomPlayer = RepositoryTestHelper.CreateTestRoomPlayer( - roomId: roomId, - userId: playerId - ); + var data = JsonDocument.Parse("{}").RootElement; + var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: "UnsupportedGame"); + room.StartedAt = DateTime.UtcNow; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); _roomPlayerRepositoryMock - .Setup(r => r.GetByRoomIdAndUserIdAsync(roomId, playerId)) - .ReturnsAsync(roomPlayer); - _roomPlayerRepositoryMock.Setup(r => r.DeleteAsync(roomPlayer.Id)).ReturnsAsync(true); - - // Act - var result = await _roomService.LeaveRoomAsync(roomId, playerId); + .Setup(r => r.IsPlayerInRoomAsync(roomId, playerId)) + .ReturnsAsync(true); - // Assert - result.Should().NotBeNull(); - result.Id.Should().Be(roomId); - _roomPlayerRepositoryMock.Verify(r => r.DeleteAsync(roomPlayer.Id), Times.Once); - _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) + ); + _mockBlackjackGameService.Verify( + s => + s.PerformActionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); } + #endregion + + #region JoinRoomAsync Tests + [Fact] - public async Task LeaveRoomAsync_ClosesRoom_WhenHostLeaves() + public async Task JoinRoomAsync_DelegatesToGameService_WhenSuccessful() { // Arrange var roomId = Guid.NewGuid(); - var hostId = Guid.NewGuid(); - - // Create a new in-memory database context that suppresses transaction warnings - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .ConfigureWarnings(w => - w.Ignore( - Microsoft - .EntityFrameworkCore - .Diagnostics - .InMemoryEventId - .TransactionIgnoredWarning - ) - ) - .Options; - var dbContext = new AppDbContext(options); - - var roomService = new RoomService( - _roomRepositoryMock.Object, - _roomPlayerRepositoryMock.Object, - _blackjackServiceMock.Object, - _deckApiServiceMock.Object, - dbContext, - _loggerMock.Object - ); - - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, hostId: hostId); - room.IsActive = true; - - var hostPlayer = RepositoryTestHelper.CreateTestRoomPlayer( - roomId: roomId, - userId: hostId, - role: Role.Admin + var userId = Guid.NewGuid(); + var room = RepositoryTestHelper.CreateTestRoom( + id: roomId, + isActive: true, + maxPlayers: 6, + gameMode: GameModes.Blackjack ); - var otherPlayers = new List - { - hostPlayer, - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId), - RepositoryTestHelper.CreateTestRoomPlayer(roomId: roomId), - }; - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.GetByRoomIdAndUserIdAsync(roomId, hostId)) - .ReturnsAsync(hostPlayer); - _roomPlayerRepositoryMock.Setup(r => r.GetByRoomIdAsync(roomId)).ReturnsAsync(otherPlayers); - _roomRepositoryMock.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync(room); - _roomPlayerRepositoryMock.Setup(r => r.DeleteAsync(It.IsAny())).ReturnsAsync(true); // Act - var result = await roomService.LeaveRoomAsync(roomId, hostId); + var result = await _roomService.JoinRoomAsync(roomId, userId); // Assert result.Should().NotBeNull(); - _roomRepositoryMock.Verify( - r => r.UpdateAsync(It.Is(r => r.IsActive == false && r.EndedAt != null)), - Times.Once - ); + result.Id.Should().Be(roomId); + _roomRepositoryMock.Verify(r => r.GetByIdAsync(roomId), Times.Once); + _mockBlackjackGameService.Verify(s => s.PlayerJoinAsync(roomId, userId), Times.Once); // Game service is called + _roomPlayerRepositoryMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); // RoomService no longer creates RoomPlayer _roomPlayerRepositoryMock.Verify( - r => r.DeleteAsync(It.IsAny()), - Times.Exactly(3) // All 3 players removed - ); + r => r.GetByRoomIdAndUserIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); // RoomService no longer checks for existing player + _roomPlayerRepositoryMock.Verify( + r => r.GetPlayerCountInRoomAsync(It.IsAny()), + Times.Never + ); // RoomService no longer checks player count } [Fact] - public async Task LeaveRoomAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() + public async Task JoinRoomAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() { // Arrange var roomId = Guid.NewGuid(); @@ -1083,185 +1303,123 @@ public async Task LeaveRoomAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() // Act & Assert await Assert.ThrowsAsync(() => - _roomService.LeaveRoomAsync(roomId, userId) + _roomService.JoinRoomAsync(roomId, userId) + ); + _mockBlackjackGameService.Verify( + s => s.PlayerJoinAsync(It.IsAny(), It.IsAny()), + Times.Never ); } [Fact] - public async Task LeaveRoomAsync_ThrowsNotFoundException_WhenPlayerNotInRoom() + public async Task JoinRoomAsync_ThrowsBadRequestException_WhenRoomIsNotActive() { // Arrange var roomId = Guid.NewGuid(); var userId = Guid.NewGuid(); - var room = RepositoryTestHelper.CreateTestRoom(id: roomId); + var room = RepositoryTestHelper.CreateTestRoom(id: roomId, isActive: false); _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.GetByRoomIdAndUserIdAsync(roomId, userId)) - .ReturnsAsync((RoomPlayer?)null); // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.LeaveRoomAsync(roomId, userId) + await Assert.ThrowsAsync(() => + _roomService.JoinRoomAsync(roomId, userId) + ); + _mockBlackjackGameService.Verify( + s => s.PlayerJoinAsync(It.IsAny(), It.IsAny()), + Times.Never ); } - #endregion - - #region PerformPlayerActionAsync Tests - [Fact] - public async Task PerformPlayerActionAsync_DelegatesToBlackjackService_WhenGameModeIsBlackjack() + public async Task JoinRoomAsync_ThrowsBadRequestException_WhenUnsupportedGameMode() { // Arrange var roomId = Guid.NewGuid(); - var playerId = Guid.NewGuid(); - var action = "hit"; - var data = JsonDocument.Parse("{}").RootElement; - - var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: GameModes.Blackjack); - room.StartedAt = DateTime.UtcNow.AddMinutes(-5); + var userId = Guid.NewGuid(); + var room = RepositoryTestHelper.CreateTestRoom( + id: roomId, + isActive: true, + gameMode: "UnsupportedGame" + ); _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, playerId)) - .ReturnsAsync(true); - _blackjackServiceMock - .Setup(s => s.PerformActionAsync(roomId, playerId, action, data)) - .Returns(Task.CompletedTask); - // Act - await _roomService.PerformPlayerActionAsync(roomId, playerId, action, data); - - // Assert - _blackjackServiceMock.Verify( - s => s.PerformActionAsync(roomId, playerId, action, data), - Times.Once + // Act & Assert + await Assert.ThrowsAsync(() => + _roomService.JoinRoomAsync(roomId, userId) + ); + _mockBlackjackGameService.Verify( + s => s.PlayerJoinAsync(It.IsAny(), It.IsAny()), + Times.Never ); } - [Fact] - public async Task PerformPlayerActionAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() - { - // Arrange - var roomId = Guid.NewGuid(); - var playerId = Guid.NewGuid(); - var data = JsonDocument.Parse("{}").RootElement; - - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync((Room?)null); + #endregion - // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) - ); - } + #region LeaveRoomAsync Tests [Fact] - public async Task PerformPlayerActionAsync_ThrowsBadRequestException_WhenGameNotStarted() + public async Task LeaveRoomAsync_DelegatesToGameService_WhenSuccessful() { // Arrange var roomId = Guid.NewGuid(); - var playerId = Guid.NewGuid(); - var data = JsonDocument.Parse("{}").RootElement; - var room = RepositoryTestHelper.CreateTestRoom(id: roomId); - room.StartedAt = null; // Game not started + var userId = Guid.NewGuid(); + var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: GameModes.Blackjack); _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) - ); + // Act + var result = await _roomService.LeaveRoomAsync(roomId, userId); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(roomId); + _roomRepositoryMock.Verify(r => r.GetByIdAsync(roomId), Times.Once); + _mockBlackjackGameService.Verify(s => s.PlayerLeaveAsync(roomId, userId), Times.Once); // Verify game service is called + _roomPlayerRepositoryMock.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never); // RoomService no longer deletes RoomPlayer + _roomPlayerRepositoryMock.Verify( + r => r.GetByRoomIdAndUserIdAsync(It.IsAny(), It.IsAny()), + Times.Never + ); // RoomService no longer checks for existing player + _roomRepositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); // Room not updated by RoomService } [Fact] - public async Task PerformPlayerActionAsync_ThrowsBadRequestException_WhenPlayerNotInRoom() + public async Task LeaveRoomAsync_ThrowsNotFoundException_WhenRoomDoesNotExist() { // Arrange var roomId = Guid.NewGuid(); - var playerId = Guid.NewGuid(); - var data = JsonDocument.Parse("{}").RootElement; - var room = RepositoryTestHelper.CreateTestRoom(id: roomId); - room.StartedAt = DateTime.UtcNow; - - _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, playerId)) - .ReturnsAsync(false); + var userId = Guid.NewGuid(); + _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync((Room?)null); // Act & Assert - await Assert.ThrowsAsync(() => - _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) + await Assert.ThrowsAsync(() => + _roomService.LeaveRoomAsync(roomId, userId) + ); + _mockBlackjackGameService.Verify( + s => s.PlayerLeaveAsync(It.IsAny(), It.IsAny()), + Times.Never ); } [Fact] - public async Task PerformPlayerActionAsync_ThrowsBadRequestException_WhenUnsupportedGameMode() + public async Task LeaveRoomAsync_ThrowsBadRequestException_WhenUnsupportedGameMode() { // Arrange var roomId = Guid.NewGuid(); - var playerId = Guid.NewGuid(); - var data = JsonDocument.Parse("{}").RootElement; + var userId = Guid.NewGuid(); var room = RepositoryTestHelper.CreateTestRoom(id: roomId, gameMode: "UnsupportedGame"); - room.StartedAt = DateTime.UtcNow; _roomRepositoryMock.Setup(r => r.GetByIdAsync(roomId)).ReturnsAsync(room); - _roomPlayerRepositoryMock - .Setup(r => r.IsPlayerInRoomAsync(roomId, playerId)) - .ReturnsAsync(true); // Act & Assert await Assert.ThrowsAsync(() => - _roomService.PerformPlayerActionAsync(roomId, playerId, "hit", data) + _roomService.LeaveRoomAsync(roomId, userId) ); - } - - #endregion - - #region i'm losing it - - [Fact] - public async Task CreateRoomAsync_CreatesRoom_WhenDtoIsValid() - { - // Arrange - var createDto = new CreateRoomDTO - { - HostId = Guid.NewGuid(), - IsPublic = true, - GameMode = GameModes.Blackjack, - GameConfig = "{}", - Description = "A valid test room", - MaxPlayers = 4, - MinPlayers = 2, - }; - - var roomToCreate = new Room - { - Id = Guid.NewGuid(), - HostId = createDto.HostId, - GameMode = createDto.GameMode, - GameState = "{}", - MaxPlayers = createDto.MaxPlayers, - MinPlayers = createDto.MinPlayers, - }; - - _roomRepositoryMock.Setup(r => r.CreateAsync(It.IsAny())).ReturnsAsync(roomToCreate); - - // Act - var result = await _roomService.CreateRoomAsync(createDto); - - // Assert - result.Should().NotBeNull(); - result.Id.Should().Be(roomToCreate.Id); - result.HostId.Should().Be(createDto.HostId); - _roomRepositoryMock.Verify( - r => - r.CreateAsync( - It.Is(room => - room.HostId == createDto.HostId && room.GameMode == createDto.GameMode - ) - ), - Times.Once + _mockBlackjackGameService.Verify( + s => s.PlayerLeaveAsync(It.IsAny(), It.IsAny()), + Times.Never ); }