Fix: correct kicker logic and split pot evaluation

- Fixed kicker calculation for ties.
- Refactored game engine to return a list of winners.
- Enabled support for multiple winners in split pot scenarios.
This commit is contained in:
Julian Kropff
2026-05-08 15:05:39 +02:00
parent 394cdf4ea1
commit b304fa04ed
4 changed files with 224 additions and 33 deletions
@@ -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<String> 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<String> 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<String> 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<String> computeWinnerNames(GameState state, GameController game) {
GamePhase phase = state.getPhase();
if (phase != GamePhase.SHOWDOWN && phase != GamePhase.FINISHED) {
return new ArrayList<>();
}
CardsSpeakRule showdown = new CardsSpeakRule();
List<Player> winners = showdown.determineWinners(state);
List<String> 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()) {
@@ -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<Integer, Long> rankCount) {
private static HandRank checkFourOfAKind(Map<Integer, Long> rankCount, List<Integer> allRanks) {
if (!rankCount.containsValue(FOUR_OF_A_KIND_COUNT)) {
return null;
}
int quad = getRank(rankCount, FOUR_OF_A_KIND);
List<Integer> kickers = getKickers(allRanks, List.of(quad), ONE_KICKER);
return new HandRank(HandRank.Type.FOUR_OF_A_KIND, List.of(quad));
List<Integer> 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<Integer, Long> rankCount) {
private static HandRank checkThreeOfAKind(Map<Integer, Long> rankCount, List<Integer> allRanks) {
if (!rankCount.containsValue(THREE_OF_A_KIND_COUNT)) {
return null;
}
int tripsRank = getRank(rankCount, THREE_OF_A_KIND_RANK);
List<Integer> kickers = getKickers(allRanks, List.of(tripsRank), TWO_KICKERS);
return new HandRank(HandRank.Type.THREE_OF_A_KIND, List.of(tripsRank));
List<Integer> 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<Integer, Long> rankCount) {
private static HandRank checkTwoPair(Map<Integer, Long> rankCount, List<Integer> 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<Integer> kickers = getKickers(allRanks, pairRanks, ONE_KICKER);
List<Integer> 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<Integer, Long> rankCount) {
private static HandRank checkOnePair(Map<Integer, Long> rankCount, List<Integer> allRanks) {
long pairCount = rankCount.values().stream().filter(v -> v == PAIR).count();
@@ -313,8 +339,12 @@ public class HandEvaluator {
}
int pair = getRank(rankCount, PAIR);
List<Integer> kickers = getKickers(allRanks, List.of(pair), THREE_KICKERS);
return new HandRank(HandRank.Type.ONE_PAIR, List.of(pair));
List<Integer> 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<Integer> getKickers(
List<Integer> allRanks, List<Integer> excludeRanks, int numKickers) {
Set<Integer> 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.
*
@@ -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<Player> 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<Player> determineWinners(GameState state) {
List<Player> players = new ArrayList<>(state.getPlayers());
Player bestPlayer = null;
HandRank bestRank = null;
List<Player> 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<Player> 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();
}
@@ -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<String> seenCards = new HashSet<>();
for (List<Card> 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<Card> board = state.getCommunityCards();
List<Card> handA = new ArrayList<>(board);
handA.addAll(state.getHoleCards(PlayerId.of("PlayerA")));
List<Card> 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<Player> 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");
}
}