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:
+58
-8
@@ -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()) {
|
||||
|
||||
+62
-12
@@ -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.
|
||||
*
|
||||
|
||||
+31
-9
@@ -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();
|
||||
}
|
||||
|
||||
+73
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user