diff --git a/poker/constants.py b/poker/constants.py index 04a8b34..e0a7348 100644 --- a/poker/constants.py +++ b/poker/constants.py @@ -3,6 +3,21 @@ from enum import IntEnum, auto from poker.utils.enum import AutoName +class Rank(IntEnum): + """Poker rank enum.""" + + ROYAL_FLUSH = 1 + STRAIGHT_FLUSH = 2 + FOUR_OF_A_KIND = 3 + FULL_HOUSE = 4 + FLUSH = 5 + STRAIGHT = 6 + THREE_OF_A_KIND = 7 + TWO_PAIR = 8 + PAIR = 9 + HIGH_CARD = 10 + + class Suit(AutoName): """Card suit enum.""" @@ -15,7 +30,6 @@ class Suit(AutoName): class Value(IntEnum): """Card value enum.""" - ACE = 1 TWO = 2 THREE = 3 FOUR = 4 @@ -28,3 +42,12 @@ class Value(IntEnum): JACK = 11 QUEEN = 12 KING = 13 + ACE = 14 + + def __str__(self) -> str: + """Return string representation.""" + if self.value in [1, 11, 12, 13]: + out: str = self.name.lower() + else: + out = str(self.value) + return out diff --git a/poker/models.py b/poker/models.py index 1d6af05..bbb474c 100644 --- a/poker/models.py +++ b/poker/models.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections import Counter + from pydantic import BaseModel, validator from poker.constants import Rank, Suit, Value @@ -43,6 +45,11 @@ class Hand(BaseModel): raise ValueError("Hand must have five cards.") return cards + @property + def value_counts(self) -> list[tuple[Value, int]]: + """Return count of each card value in hand.""" + return Counter([card.value for card in self.cards]).most_common() + class RankedHand(Hand): """Ranked hand domain model class.""" diff --git a/poker/rank/__init__.py b/poker/rank/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/poker/rank/descriptions.py b/poker/rank/descriptions.py new file mode 100644 index 0000000..e69de29 diff --git a/poker/rank/hands.py b/poker/rank/hands.py new file mode 100644 index 0000000..8764ce9 --- /dev/null +++ b/poker/rank/hands.py @@ -0,0 +1,110 @@ +from poker.constants import Rank, Value +from poker.models import Card, Hand + + +def _is_flush(cards: list[Card]) -> bool: + """Calculate whether hand is a flush.""" + return len(set(card.suit for card in cards)) == 1 + + +def _is_straight(cards: list[Card]) -> bool: + """Calculate whether hand is a straight.""" + card_values = sorted([card.value for card in cards]) + + # If card values are not unique it is not a straight + if len(set(card_values)) != len(card_values): + return False + + # Check for a ace low straight + if card_values == [Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.ACE]: + return True + + # If all card differences are 1 it is a straight + diffs = [second - first for first, second in zip(card_values[:-1], card_values[1:])] + if set(diffs) == {1}: + return True + + return False + + +def is_royal_flush(hand: Hand) -> bool: + """Calculate whether hand a royal flush.""" + if not _is_flush(hand.cards): + return False + + values = [card.value for card in hand.cards] + return all( + value in values + for value in [Value.ACE, Value.KING, Value.QUEEN, Value.JACK, Value.TEN] + ) + + +def is_straight_flush(hand: Hand) -> bool: + """Calculate whether hand a straight flush.""" + return _is_straight(hand.cards) and _is_flush(hand.cards) + + +def is_four_of_a_kind(hand: Hand) -> bool: + """Calculate whether hand has four of a kind.""" + return hand.value_counts[0][1] == 4 + + +def is_full_house(hand: Hand) -> bool: + """Calculate whether hand is a full house.""" + return hand.value_counts[0][1] == 3 and hand.value_counts[1][1] == 2 + + +def is_flush(hand: Hand) -> bool: + """Calculate whether hand is a flush.""" + return _is_flush(hand.cards) and not _is_straight(hand.cards) + + +def is_straight(hand: Hand) -> bool: + """Calculate whether hand is a flush.""" + return _is_straight(hand.cards) and not _is_flush(hand.cards) + + +def is_three_of_a_kind(hand: Hand) -> bool: + """Calculate whether hand is a full house.""" + return ( + hand.value_counts[0][1] == 3 + and hand.value_counts[1][1] == 1 + and hand.value_counts[2][1] == 1 + ) + + +def is_two_pair(hand: Hand) -> bool: + """Calculate whether hand is a two pair.""" + return hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 2 + + +def is_pair(hand: Hand) -> bool: + """Calculate whether hand is a pair.""" + return hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 1 + + +def get_rank(hand: Hand) -> Rank: + """Get hand rank. + + TODO: Use a factory pattern to avoid this huge flow control. + """ + if is_royal_flush(hand): + return Rank.ROYAL_FLUSH + elif is_straight_flush(hand): + return Rank.STRAIGHT_FLUSH + elif is_four_of_a_kind(hand): + return Rank.FOUR_OF_A_KIND + elif is_full_house(hand): + return Rank.FULL_HOUSE + elif is_flush(hand): + return Rank.FLUSH + elif is_straight(hand): + return Rank.STRAIGHT + elif is_three_of_a_kind(hand): + return Rank.THREE_OF_A_KIND + elif is_two_pair(hand): + return Rank.TWO_PAIR + elif is_pair(hand): + return Rank.PAIR + else: + return Rank.HIGH_CARD diff --git a/pyproject.toml b/pyproject.toml index 5565627..5c4b912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "poker" -version = "0.2.4" +version = "1.0.0" description = "Single poker hand ranking service." authors = ["Paul Harrison"] readme = "README.md" diff --git a/tests/test_rank.py b/tests/test_rank.py new file mode 100644 index 0000000..f504a3e --- /dev/null +++ b/tests/test_rank.py @@ -0,0 +1,446 @@ +from itertools import cycle + +import pytest + +from poker.constants import Suit, Value +from poker.models import Card, Hand +from poker.rank.hands import ( + _is_flush, + _is_straight, + is_flush, + is_four_of_a_kind, + is_full_house, + is_pair, + is_royal_flush, + is_straight, + is_straight_flush, + is_three_of_a_kind, + is_two_pair, +) +from tests.conftest import Factory + + +@pytest.mark.parametrize( + "suits,expected", + [ + ([Suit.CLUBS, Suit.DIAMONDS, Suit.SPADES, Suit.HEARTS, Suit.CLUBS], False), + ([Suit.CLUBS, Suit.CLUBS, Suit.CLUBS, Suit.CLUBS, Suit.CLUBS], True), + ], + ids=[ + "non-unique", + "unique", + ], +) +def test__is_flush( + card_factory: Factory[Card], suits: list[Suit], expected: bool +) -> None: + result = _is_flush([card_factory(suit=suit) for suit in suits]) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "values,expected", + [ + ([Value.TWO, Value.TWO, Value.THREE, Value.FOUR, Value.FIVE], False), + ([Value.THREE, Value.TWO, Value.ACE, Value.TEN, Value.KING], False), + ([Value.ACE, Value.TWO, Value.THREE, Value.FOUR, Value.FIVE], True), + ([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], True), + ([Value.THREE, Value.FIVE, Value.TWO, Value.FOUR, Value.SIX], True), + ], + ids=[ + "non-unique", + "not-flush", + "low-ace", + "high-ace", + "middle-flush", + ], +) +def test__is_straight( + card_factory: Factory[Card], values: list[Value], expected: bool +) -> None: + suits = cycle(Suit) + result = _is_straight( + [card_factory(suit=suit, value=value) for suit, value in zip(suits, values)] + ) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "values,different_suits,expected", + [ + ([Value.TWO, Value.TEN, Value.THREE, Value.FOUR, Value.FIVE], False, False), + ([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], False, True), + ([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], True, False), + ], + ids=[ + "not-flush", + "royal-flush", + "flush-not-royal", + ], +) +def test_is_royal_flush( + card_factory: Factory[Card], + values: list[Value], + different_suits: bool, + expected: bool, +) -> None: + suits = cycle(Suit) + + if different_suits: + result = is_royal_flush( + Hand( + cards=[ + card_factory(suit=suit, value=value) + for suit, value in zip(suits, values) + ] + ) + ) + else: + result = is_royal_flush( + Hand(cards=[card_factory(value=value) for value in values]) + ) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "values,different_suits,expected", + [ + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, False), + ([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, False), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, True), + ], + ids=[ + "straight-only", + "flush-only", + "straight-flush", + ], +) +def test_is_straight_flush( + card_factory: Factory[Card], + values: list[Value], + different_suits: bool, + expected: bool, +) -> None: + suits = cycle(Suit) + + if different_suits: + result = is_straight_flush( + Hand( + cards=[ + card_factory(suit=suit, value=value) + for suit, value in zip(suits, values) + ] + ) + ) + else: + result = is_straight_flush( + Hand(cards=[card_factory(value=value) for value in values]) + ) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "cards,expected", + [ + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.TWO), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + True, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TEN), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.TWO), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + False, + ), + ], + ids=[ + "four-of-a-kind", + "not-four-of-a-kind", + ], +) +def test_is_four_of_a_kind(cards: list[Card], expected: bool) -> None: + result = is_four_of_a_kind(Hand(cards=cards)) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "cards,expected", + [ + ( + [ + Card(suit=Suit.CLUBS, value=Value.THREE), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.THREE), + ], + True, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.TWO), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + False, + ), + ], + ids=[ + "full-house", + "not-full-house", + ], +) +def test_is_full_house(cards: list[Card], expected: bool) -> None: + result = is_full_house(Hand(cards=cards)) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "values,different_suits,expected", + [ + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, False), + ([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, True), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, False), + ], + ids=[ + "straight-only", + "flush-only", + "straight-flush", + ], +) +def test_is_flush( + card_factory: Factory[Card], + values: list[Value], + different_suits: bool, + expected: bool, +) -> None: + suits = cycle(Suit) + + if different_suits: + result = is_flush( + Hand( + cards=[ + card_factory(suit=suit, value=value) + for suit, value in zip(suits, values) + ] + ) + ) + else: + result = is_flush(Hand(cards=[card_factory(value=value) for value in values])) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "values,different_suits,expected", + [ + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, True), + ([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, False), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, False), + ], + ids=[ + "straight-only", + "flush-only", + "straight-flush", + ], +) +def test_is_straight( + card_factory: Factory[Card], + values: list[Value], + different_suits: bool, + expected: bool, +) -> None: + suits = cycle(Suit) + + if different_suits: + result = is_straight( + Hand( + cards=[ + card_factory(suit=suit, value=value) + for suit, value in zip(suits, values) + ] + ) + ) + else: + result = is_straight( + Hand(cards=[card_factory(value=value) for value in values]) + ) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "cards,expected", + [ + ( + [ + Card(suit=Suit.CLUBS, value=Value.THREE), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.THREE), + ], + False, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.ACE), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + True, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TEN), + Card(suit=Suit.SPADES, value=Value.ACE), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + False, + ), + ], + ids=["full-house", "three-of-a-kind", "not-three-of-a-kind"], +) +def test_is_three_of_a_kind(cards: list[Card], expected: bool) -> None: + result = is_three_of_a_kind(Hand(cards=cards)) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "cards,expected", + [ + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.THREE), + Card(suit=Suit.DIAMONDS, value=Value.THREE), + ], + False, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.ACE), + Card(suit=Suit.SPADES, value=Value.ACE), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + True, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.ACE), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + False, + ), + ], + ids=["full-house", "two-pair", "three-of-a-kind"], +) +def test_is_two_pair(cards: list[Card], expected: bool) -> None: + result = is_two_pair(Hand(cards=cards)) + + if expected: + assert result + else: + assert not result + + +@pytest.mark.parametrize( + "cards,expected", + [ + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.TWO), + Card(suit=Suit.SPADES, value=Value.THREE), + Card(suit=Suit.DIAMONDS, value=Value.THREE), + ], + False, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.ACE), + Card(suit=Suit.SPADES, value=Value.ACE), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + False, + ), + ( + [ + Card(suit=Suit.CLUBS, value=Value.TWO), + Card(suit=Suit.DIAMONDS, value=Value.TWO), + Card(suit=Suit.HEARTS, value=Value.KING), + Card(suit=Suit.SPADES, value=Value.ACE), + Card(suit=Suit.CLUBS, value=Value.THREE), + ], + True, + ), + ], + ids=["full-house", "two-pair", "pair"], +) +def test_is_pair(cards: list[Card], expected: bool) -> None: + result = is_pair(Hand(cards=cards)) + + if expected: + assert result + else: + assert not result