diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java index f55fd36..43e323a 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/app/commands/game/get_game_state/GetGameStateResponse.java @@ -6,6 +6,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Rank; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Suit; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.showdown.CardsSpeakRule; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; import ch.unibas.dmi.dbis.cs108.casono.server.domain.highscore.HighscoreService; @@ -13,8 +14,11 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestCo import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody; import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBodyBuilder; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.Set; /** * Response carrying a snapshot of the current game state using the project's wire format. @@ -51,10 +55,19 @@ public class GetGameStateResponse extends SuccessResponse { builder.param("CURRENT_BET", computeGlobalCurrentBet(state)); builder.param("DEALER", state.getDealerIndex()); builder.param("ACTIVE_PLAYER", state.getCurrentPlayerIndex()); + int winnerIndex = computeWinnerIndex(state, game); builder.param("WINNER", winnerIndex); - appendWinnerToHighscoresIfFinished(state, winnerIndex); + List winnerNames = computeWinnerNames(state, game); + int potPerWinner = computePotPerWinner(state, winnerNames.size()); + + for (String name : winnerNames) { + builder.param("WINNER_NAME", name); + } + builder.param("POT_PER_WINNER", potPerWinner); + + appendWinnerToHighscoresIfFinished(state, winnerNames); appendHighscoreEntries(builder); appendCommunityCards(builder, state); @@ -64,8 +77,13 @@ public class GetGameStateResponse extends SuccessResponse { return builder.build(); } - private static void appendWinnerToHighscoresIfFinished(GameState state, int winnerIndex) { - if (winnerIndex < 0 || state.getPhase() != GamePhase.FINISHED) { + private static void appendWinnerToHighscoresIfFinished(GameState state, List winnerNames) { + if (winnerNames == null || winnerNames.isEmpty()) { + return; + } + + GamePhase phase = state.getPhase(); + if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) { return; } @@ -73,12 +91,13 @@ public class GetGameStateResponse extends SuccessResponse { return; } - Player winner = findPlayerByIndex(state, winnerIndex); - if (winner == null || winner.getId() == null || winner.getId().value() == null) { - return; + // Keep ordering stable, avoid duplicate writes for the same winner in one hand. + Set uniqueWinners = new LinkedHashSet<>(winnerNames); + for (String name : uniqueWinners) { + if (name != null && !name.isBlank()) { + HighscoreService.getInstance().appendWinner(name); + } } - - HighscoreService.getInstance().appendWinner(winner.getId().value()); } private static Player findPlayerByIndex(GameState state, int winnerIndex) { @@ -120,6 +139,37 @@ public class GetGameStateResponse extends SuccessResponse { return -1; } + /** + * Computes the list of all winner names at showdown/finished phase. + */ + private static List computeWinnerNames(GameState state, GameController game) { + GamePhase phase = state.getPhase(); + if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) { + return new ArrayList<>(); + } + + CardsSpeakRule showdown = new CardsSpeakRule(); + List winners = showdown.determineWinners(state); + + List winnerNames = new ArrayList<>(); + for (Player winner : winners) { + if (winner != null && winner.getId() != null && winner.getId().value() != null) { + winnerNames.add(winner.getId().value()); + } + } + return winnerNames; + } + + /** + * Computes the pot share per winner. + */ + private static int computePotPerWinner(GameState state, int numWinners) { + if (numWinners <= 0 || state.getPot() == null) { + return 0; + } + return state.getPot().getAmount() / numWinners; + } + private static int computeGlobalCurrentBet(GameState state) { int globalCurrentBet = 0; for (Player p : state.getPlayers()) { diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/evaluator/HandEvaluator.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/evaluator/HandEvaluator.java index daf293e..3dc11e4 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/evaluator/HandEvaluator.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/evaluator/HandEvaluator.java @@ -2,6 +2,7 @@ package ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Card; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Suit; +import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -40,6 +41,9 @@ public class HandEvaluator { private static final int START_INDEX = 1; private static final int CARD_DIFFERENCE = 1; private static final int PREVIOUS_INDEX_OFFSET = -1; + private static final int TWO_KICKERS = 2; + private static final int ONE_KICKER = 1; + private static final int THREE_KICKERS = 3; /** * Evaluates a list of cards and determines the best possible hand rank. @@ -62,7 +66,7 @@ public class HandEvaluator { return straightFlush; } - HandRank fourKind = checkFourOfAKind(rankCount); + HandRank fourKind = checkFourOfAKind(rankCount, ranks); if (fourKind != null) { return fourKind; } @@ -80,17 +84,17 @@ public class HandEvaluator { return new HandRank(HandRank.Type.STRAIGHT, ranks); } - HandRank threeKind = checkThreeOfAKind(rankCount); + HandRank threeKind = checkThreeOfAKind(rankCount, ranks); if (threeKind != null) { return threeKind; } - HandRank twoPair = checkTwoPair(rankCount); + HandRank twoPair = checkTwoPair(rankCount, ranks); if (twoPair != null) { return twoPair; } - HandRank onePair = checkOnePair(rankCount); + HandRank onePair = checkOnePair(rankCount, ranks); if (onePair != null) { return onePair; } @@ -201,19 +205,25 @@ public class HandEvaluator { /** * Helper method to check for a four of a kind hand rank. + * Stores the quad rank and 1 kicker for tie-breaking. * * @param rankCount A map where the key is the card rank and the value is the count of * occurrences. + * @param allRanks A sorted list of all card ranks in the hand. * @return A HandRank object representing the four of a kind hand rank, or null if not found. */ - private static HandRank checkFourOfAKind(Map rankCount) { + private static HandRank checkFourOfAKind(Map rankCount, List allRanks) { if (!rankCount.containsValue(FOUR_OF_A_KIND_COUNT)) { return null; } int quad = getRank(rankCount, FOUR_OF_A_KIND); + List kickers = getKickers(allRanks, List.of(quad), ONE_KICKER); - return new HandRank(HandRank.Type.FOUR_OF_A_KIND, List.of(quad)); + List result = new ArrayList<>(List.of(quad)); + result.addAll(kickers); + + return new HandRank(HandRank.Type.FOUR_OF_A_KIND, result); } /** @@ -256,29 +266,38 @@ public class HandEvaluator { /** * Helper method to check for a three of a kind hand rank. + * Stores the trips rank and 2 kickers for tie-breaking. * * @param rankCount A map where the key is the card rank and the value is the count of * occurrences. + * @param allRanks A sorted list of all card ranks in the hand. * @return A HandRank object representing the three of a kind hand rank, or null if not found. */ - private static HandRank checkThreeOfAKind(Map rankCount) { + private static HandRank checkThreeOfAKind(Map rankCount, List allRanks) { if (!rankCount.containsValue(THREE_OF_A_KIND_COUNT)) { return null; } int tripsRank = getRank(rankCount, THREE_OF_A_KIND_RANK); + List kickers = getKickers(allRanks, List.of(tripsRank), TWO_KICKERS); - return new HandRank(HandRank.Type.THREE_OF_A_KIND, List.of(tripsRank)); + List result = new ArrayList<>(List.of(tripsRank)); + result.addAll(kickers); + + return new HandRank(HandRank.Type.THREE_OF_A_KIND, result); } /** * Helper method to check for a two pair hand rank. + * Stores the two pair ranks and 1 kicker for tie-breaking. + * The kicker is the highest remaining card that is not part of either pair. * * @param rankCount A map where the key is the card rank and the value is the count of * occurrences. + * @param allRanks A sorted list of all card ranks in the hand. * @return A HandRank object representing the two pair hand rank, or null if not found. */ - private static HandRank checkTwoPair(Map rankCount) { + private static HandRank checkTwoPair(Map rankCount, List allRanks) { long pairCount = rankCount.values().stream().filter(v -> v == PAIR).count(); @@ -294,17 +313,24 @@ public class HandEvaluator { .limit(PAIR) .toList(); - return new HandRank(HandRank.Type.TWO_PAIR, pairRanks); + List kickers = getKickers(allRanks, pairRanks, ONE_KICKER); + + List result = new ArrayList<>(pairRanks); + result.addAll(kickers); + + return new HandRank(HandRank.Type.TWO_PAIR, result); } /** * Helper method to check for a one pair hand rank. + * Stores the pair rank and 3 kickers for tie-breaking. * * @param rankCount A map where the key is the card rank and the value is the count of * occurrences. + * @param allRanks A sorted list of all card ranks in the hand. * @return A HandRank object representing the one pair hand rank, or null if not found. */ - private static HandRank checkOnePair(Map rankCount) { + private static HandRank checkOnePair(Map rankCount, List allRanks) { long pairCount = rankCount.values().stream().filter(v -> v == PAIR).count(); @@ -313,8 +339,12 @@ public class HandEvaluator { } int pair = getRank(rankCount, PAIR); + List kickers = getKickers(allRanks, List.of(pair), THREE_KICKERS); - return new HandRank(HandRank.Type.ONE_PAIR, List.of(pair)); + List result = new ArrayList<>(List.of(pair)); + result.addAll(kickers); + + return new HandRank(HandRank.Type.ONE_PAIR, result); } /** @@ -333,6 +363,26 @@ public class HandEvaluator { .orElse(DEFAULT_VALUE); } + /** + * Helper method to extract kickers from the list of all card ranks. + * Excludes the ranks already used in the main hand combination. + * + * @param allRanks A sorted list of all card ranks in the hand. + * @param excludeRanks A list of ranks to exclude (e.g., ranks used in pairs, trips, etc.). + * @param numKickers The number of kickers to extract. + * @return A list of kicker ranks, sorted in descending order. + */ + private static List getKickers( + List allRanks, List excludeRanks, int numKickers) { + + Set excludeSet = new HashSet<>(excludeRanks); + + return allRanks.stream() + .filter(rank -> !excludeSet.contains(rank)) + .limit(numKickers) + .toList(); + } + /** * Helper method to determine if a list of card ranks forms a straight. * diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/rules/showdown/CardsSpeakRule.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/rules/showdown/CardsSpeakRule.java index 2be2607..fe8d0ad 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/rules/showdown/CardsSpeakRule.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/rules/showdown/CardsSpeakRule.java @@ -5,7 +5,6 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.deck.Card; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandEvaluator; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandRank; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player; -import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerStatus; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.Rule; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; import java.util.ArrayList; @@ -43,14 +42,27 @@ public class CardsSpeakRule implements Rule { */ public Player determineWinner(GameState state) { + List winners = determineWinners(state); + return winners.isEmpty() ? null : winners.get(0); + } + + /** + * Determines all players that share the best hand rank. + * + * @param state The current state of the game. + * @return The list of winners, ordered by appearance in the state. + */ + public List determineWinners(GameState state) { + List players = new ArrayList<>(state.getPlayers()); - Player bestPlayer = null; HandRank bestRank = null; + List winners = new ArrayList<>(); for (Player player : players) { - if (player.getStatus() == PlayerStatus.FOLDED) { + // Use folded flag from game flow to stay consistent with winner calculation in controller. + if (player.isFolded()) { continue; } @@ -62,13 +74,15 @@ public class CardsSpeakRule implements Rule { HandRank rank = HandEvaluator.evaluate(cards); if (bestRank == null || rank.compareTo(bestRank) > 0) { - bestRank = rank; - bestPlayer = player; + winners.clear(); + winners.add(player); + } else if (rank.compareTo(bestRank) == 0) { + winners.add(player); } } - return bestPlayer; + return winners; } /** @@ -81,15 +95,23 @@ public class CardsSpeakRule implements Rule { */ public void awardPot(GameState state) { - Player winner = determineWinner(state); + List winners = determineWinners(state); - if (winner == null) { + if (winners.isEmpty()) { return; } int pot = state.getPot().getAmount(); + int share = winners.isEmpty() ? 0 : pot / winners.size(); + int remainder = winners.isEmpty() ? 0 : pot % winners.size(); - winner.addChips(pot); + for (int i = 0; i < winners.size(); i++) { + Player winner = winners.get(i); + if (winner == null) { + continue; + } + winner.addChips(share + (i < remainder ? 1 : 0)); + } state.getPot().reset(); } diff --git a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameControllerTest.java b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameControllerTest.java index f926806..fe20b16 100644 --- a/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameControllerTest.java +++ b/src/test/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/game/GameControllerTest.java @@ -16,6 +16,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandEvaluato import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.evaluator.HandRank; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.Player; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.player.PlayerId; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.showdown.CardsSpeakRule; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.rules.RuleEngine; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GamePhase; import ch.unibas.dmi.dbis.cs108.casono.server.domain.game.state.GameState; @@ -281,13 +282,9 @@ public class GameControllerTest { Set seenCards = new HashSet<>(); for (List cards : playerCards.values()) { - for (Card c : cards) { - String key = c.getRank() + "-" + c.getSuit(); - assertFalse(seenCards.contains(key), "Duplicate card found: " + key); - seenCards.add(key); } } @@ -617,4 +614,76 @@ public class GameControllerTest { LOGGER.info("Test 5 was successfully completed"); } + + /** + * This test simulates a scenario where two players have identical Two Pair hands at showdown. + */ + @Test + public void testSplitPotWithTrueIdenticalTwoPair() { + + GameState state = new GameState(); + + state.addPlayer(PlayerId.of("PlayerA"), 5000); + state.addPlayer(PlayerId.of("PlayerB"), 5000); + + state.getHoleCards(PlayerId.of("PlayerA")).add(new Card(Suit.CLUBS, Rank.TWO)); + state.getHoleCards(PlayerId.of("PlayerA")).add(new Card(Suit.DIAMONDS, Rank.THREE)); + state.getHoleCards(PlayerId.of("PlayerB")).add(new Card(Suit.HEARTS, Rank.FOUR)); + state.getHoleCards(PlayerId.of("PlayerB")).add(new Card(Suit.SPADES, Rank.FIVE)); + + state.addCommunityCard(new Card(Suit.CLUBS, Rank.QUEEN)); + state.addCommunityCard(new Card(Suit.DIAMONDS, Rank.QUEEN)); + state.addCommunityCard(new Card(Suit.SPADES, Rank.EIGHT)); + state.addCommunityCard(new Card(Suit.HEARTS, Rank.EIGHT)); + state.addCommunityCard(new Card(Suit.CLUBS, Rank.ACE)); + + List board = state.getCommunityCards(); + + List handA = new ArrayList<>(board); + handA.addAll(state.getHoleCards(PlayerId.of("PlayerA"))); + + List handB = new ArrayList<>(board); + handB.addAll(state.getHoleCards(PlayerId.of("PlayerB"))); + + HandRank rankA = HandEvaluator.evaluate(handA); + HandRank rankB = HandEvaluator.evaluate(handB); + + assertEquals(0, rankA.compareTo(rankB), "Both hands must be exactly the same"); + + CardsSpeakRule showdown = new CardsSpeakRule(); + List winners = showdown.determineWinners(state); + + assertEquals(2, winners.size(), "There must be two winners"); + assertEquals("PlayerA", winners.get(0).getName()); + assertEquals("PlayerB", winners.get(1).getName()); + + int chipsBeforeA = state.getPlayers().stream() + .filter(p -> p.getId().equals(PlayerId.of("PlayerA"))) + .findFirst() + .orElseThrow() + .getChips(); + int chipsBeforeB = state.getPlayers().stream() + .filter(p -> p.getId().equals(PlayerId.of("PlayerB"))) + .findFirst() + .orElseThrow() + .getChips(); + + state.getPot().add(1000); + showdown.awardPot(state); + + Player playerA = state.getPlayers().stream() + .filter(p -> p.getId().equals(PlayerId.of("PlayerA"))) + .findFirst() + .orElseThrow(); + Player playerB = state.getPlayers().stream() + .filter(p -> p.getId().equals(PlayerId.of("PlayerB"))) + .findFirst() + .orElseThrow(); + + assertEquals(chipsBeforeA + 500, playerA.getChips(), "Player A must receive half the pot"); + assertEquals(chipsBeforeB + 500, playerB.getChips(), "Player B must receive half the pot"); + assertEquals(0, state.getPot().getAmount(), "The pot must be 0 after the payout"); + + LOGGER.info("Passed the Split-Pot-Test"); + } }