From b4ba284dcc20563b9b7dde770b7c9f67c6b25e76 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Sun, 20 Nov 2022 13:39:08 +0000 Subject: [PATCH] feat: Construct ranked hand with description Refactors rank test functions to retern details required for description, then return RankedHand from rank_hand function. --- poker/rank/__init__.py | 3 + poker/rank/descriptions.py | 14 +++ poker/rank/hands.py | 160 +++++++++++++++++++--------- pyproject.toml | 2 +- tests/test_rank.py | 206 ++++++++++++++++++------------------- 5 files changed, 231 insertions(+), 154 deletions(-) diff --git a/poker/rank/__init__.py b/poker/rank/__init__.py index e69de29..c8700f9 100644 --- a/poker/rank/__init__.py +++ b/poker/rank/__init__.py @@ -0,0 +1,3 @@ +from poker.rank.hands import rank_hand + +__all__ = ["rank_hand"] diff --git a/poker/rank/descriptions.py b/poker/rank/descriptions.py index e69de29..e7f367a 100644 --- a/poker/rank/descriptions.py +++ b/poker/rank/descriptions.py @@ -0,0 +1,14 @@ +from poker.constants import Rank + +DESCRIPTIONS = { + Rank.ROYAL_FLUSH: "royal flush: {suit}", + Rank.STRAIGHT_FLUSH: "straight flush: {high}-high {suit}", + Rank.FOUR_OF_A_KIND: "four of a kind: {value}", + Rank.FULL_HOUSE: "full house: {trips} over {pair}", + Rank.FLUSH: "flush: {suit}", + Rank.STRAIGHT: "straight: {high}-high", + Rank.THREE_OF_A_KIND: "three of a kind: {value}", + Rank.TWO_PAIR: "two pair: {high} and {low}", + Rank.PAIR: "pair: {value}", + Rank.HIGH_CARD: "high card: {value}", +} diff --git a/poker/rank/hands.py b/poker/rank/hands.py index 8764ce9..6a81af5 100644 --- a/poker/rank/hands.py +++ b/poker/rank/hands.py @@ -1,5 +1,8 @@ +from typing import Optional, Union + from poker.constants import Rank, Value -from poker.models import Card, Hand +from poker.models import Card, Hand, RankedHand +from poker.rank.descriptions import DESCRIPTIONS def _is_flush(cards: list[Card]) -> bool: @@ -7,104 +10,165 @@ def _is_flush(cards: list[Card]) -> bool: return len(set(card.suit for card in cards)) == 1 -def _is_straight(cards: list[Card]) -> bool: - """Calculate whether hand is a straight.""" +def _is_straight(cards: list[Card]) -> Optional[Value]: + """Calculate whether hand is a straight. + + Arguments: + cards: List of cards in hand. + + Returns: + High card if hand is a straight, None otherwise. + """ 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 + return None # Check for a ace low straight if card_values == [Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.ACE]: - return True + return Value.FIVE # 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 max(cards).value - return False + return None -def is_royal_flush(hand: Hand) -> bool: +def royal_flush(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand a royal flush.""" if not _is_flush(hand.cards): - return False + return {} values = [card.value for card in hand.cards] - return all( + _is = all( value in values for value in [Value.ACE, Value.KING, Value.QUEEN, Value.JACK, Value.TEN] ) + if _is: + return {"suit": hand.cards[0].suit} + + return {} -def is_straight_flush(hand: Hand) -> bool: +def straight_flush(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand a straight flush.""" - return _is_straight(hand.cards) and _is_flush(hand.cards) + high = _is_straight(hand.cards) + if high is not None and _is_flush(hand.cards): + return { + "high": high, + "suit": hand.cards[0].suit, + } + + return {} -def is_four_of_a_kind(hand: Hand) -> bool: +def four_of_a_kind(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand has four of a kind.""" - return hand.value_counts[0][1] == 4 + if hand.value_counts[0][1] == 4: + return {"value": hand.value_counts[0][0]} + + return {} -def is_full_house(hand: Hand) -> bool: +def full_house(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand is a full house.""" - return hand.value_counts[0][1] == 3 and hand.value_counts[1][1] == 2 + if hand.value_counts[0][1] == 3 and hand.value_counts[1][1] == 2: + return { + "trips": hand.value_counts[0][0], + "pair": hand.value_counts[1][0], + } + + return {} -def is_flush(hand: Hand) -> bool: +def flush(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand is a flush.""" - return _is_flush(hand.cards) and not _is_straight(hand.cards) + high = _is_straight(hand.cards) + if _is_flush(hand.cards) and high is None: + return {"suit": hand.cards[0].suit} + + return {} -def is_straight(hand: Hand) -> bool: +def straight(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand is a flush.""" - return _is_straight(hand.cards) and not _is_flush(hand.cards) + high = _is_straight(hand.cards) + if high is not None and not _is_flush(hand.cards): + return {"high": high} + + return {} -def is_three_of_a_kind(hand: Hand) -> bool: +def three_of_a_kind(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand is a full house.""" - return ( + if ( hand.value_counts[0][1] == 3 and hand.value_counts[1][1] == 1 and hand.value_counts[2][1] == 1 - ) + ): + return {"value": hand.value_counts[0][0]} + + return {} -def is_two_pair(hand: Hand) -> bool: +def two_pair(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand is a two pair.""" - return hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 2 + if hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 2: + values = [hand.value_counts[0][0], hand.value_counts[1][0]] + return { + "high": max(values), + "low": min(values), + } + + return {} -def is_pair(hand: Hand) -> bool: +def pair(hand: Hand) -> dict[str, Union[str, int]]: """Calculate whether hand is a pair.""" - return hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 1 + if hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 1: + return {"value": hand.value_counts[0][0]} + + return {} -def get_rank(hand: Hand) -> Rank: +def high_card(hand: Hand) -> dict[str, Union[str, int]]: + """Get high card.""" + return {"value": max(hand.cards).value} + + +_FUNCTIONS = { + Rank.ROYAL_FLUSH: royal_flush, + Rank.STRAIGHT_FLUSH: straight_flush, + Rank.FOUR_OF_A_KIND: four_of_a_kind, + Rank.FULL_HOUSE: full_house, + Rank.FLUSH: flush, + Rank.STRAIGHT: straight, + Rank.THREE_OF_A_KIND: three_of_a_kind, + Rank.TWO_PAIR: two_pair, + Rank.PAIR: pair, + Rank.HIGH_CARD: high_card, +} + + +def rank_hand(hand: Hand) -> RankedHand: """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 + out = None + for rank, func in _FUNCTIONS.items(): + result = func(hand) + if result: + out = RankedHand( + cards=hand.cards, + rank=rank, + description=DESCRIPTIONS[rank].format(**result), + ) + + if out is None: + raise ValueError("No rank found.") + + return out diff --git a/pyproject.toml b/pyproject.toml index 5c4b912..036f515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "poker" -version = "1.0.0" +version = "1.1.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 index f504a3e..219440f 100644 --- a/tests/test_rank.py +++ b/tests/test_rank.py @@ -1,4 +1,5 @@ from itertools import cycle +from typing import Optional, Union import pytest @@ -7,15 +8,16 @@ 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, + flush, + four_of_a_kind, + full_house, + high_card, + pair, + royal_flush, + straight, + straight_flush, + three_of_a_kind, + two_pair, ) from tests.conftest import Factory @@ -32,7 +34,7 @@ from tests.conftest import Factory ], ) def test__is_flush( - card_factory: Factory[Card], suits: list[Suit], expected: bool + card_factory: Factory[Card], suits: list[Suit], expected: dict[str, Union[str, int]] ) -> None: result = _is_flush([card_factory(suit=suit) for suit in suits]) @@ -45,11 +47,11 @@ def test__is_flush( @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), + ([Value.TWO, Value.TWO, Value.THREE, Value.FOUR, Value.FIVE], None), + ([Value.THREE, Value.TWO, Value.ACE, Value.TEN, Value.KING], None), + ([Value.ACE, Value.TWO, Value.THREE, Value.FOUR, Value.FIVE], Value.FIVE), + ([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], Value.ACE), + ([Value.THREE, Value.FIVE, Value.TWO, Value.FOUR, Value.SIX], Value.SIX), ], ids=[ "non-unique", @@ -60,25 +62,26 @@ def test__is_flush( ], ) def test__is_straight( - card_factory: Factory[Card], values: list[Value], expected: bool + card_factory: Factory[Card], values: list[Value], expected: Optional[Value] ) -> 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 + assert result == expected @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), + ([Value.TWO, Value.TEN, Value.THREE, Value.FOUR, Value.FIVE], False, {}), + ( + [Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], + False, + {"suit": Suit.CLUBS}, + ), + ([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], True, {}), ], ids=[ "not-flush", @@ -90,12 +93,12 @@ def test_is_royal_flush( card_factory: Factory[Card], values: list[Value], different_suits: bool, - expected: bool, + expected: dict[str, Union[str, int]], ) -> None: suits = cycle(Suit) if different_suits: - result = is_royal_flush( + result = royal_flush( Hand( cards=[ card_factory(suit=suit, value=value) @@ -104,22 +107,23 @@ def test_is_royal_flush( ) ) else: - result = is_royal_flush( + result = royal_flush( Hand(cards=[card_factory(value=value) for value in values]) ) - if expected: - assert result - else: - assert not result + assert result == expected @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), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, {}), + ([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, {}), + ( + [Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], + False, + {"high": Value.SIX, "suit": Suit.CLUBS}, + ), ], ids=[ "straight-only", @@ -131,12 +135,12 @@ def test_is_straight_flush( card_factory: Factory[Card], values: list[Value], different_suits: bool, - expected: bool, + expected: dict[str, Union[str, int]], ) -> None: suits = cycle(Suit) if different_suits: - result = is_straight_flush( + result = straight_flush( Hand( cards=[ card_factory(suit=suit, value=value) @@ -145,14 +149,11 @@ def test_is_straight_flush( ) ) else: - result = is_straight_flush( + result = straight_flush( Hand(cards=[card_factory(value=value) for value in values]) ) - if expected: - assert result - else: - assert not result + assert result == expected @pytest.mark.parametrize( @@ -166,7 +167,7 @@ def test_is_straight_flush( Card(suit=Suit.SPADES, value=Value.TWO), Card(suit=Suit.CLUBS, value=Value.THREE), ], - True, + {"value": Value.TWO}, ), ( [ @@ -176,7 +177,7 @@ def test_is_straight_flush( Card(suit=Suit.SPADES, value=Value.TWO), Card(suit=Suit.CLUBS, value=Value.THREE), ], - False, + {}, ), ], ids=[ @@ -184,13 +185,12 @@ def test_is_straight_flush( "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)) +def test_is_four_of_a_kind( + cards: list[Card], expected: dict[str, Union[str, int]] +) -> None: + result = four_of_a_kind(Hand(cards=cards)) - if expected: - assert result - else: - assert not result + assert result == expected @pytest.mark.parametrize( @@ -204,7 +204,7 @@ def test_is_four_of_a_kind(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.TWO), Card(suit=Suit.SPADES, value=Value.THREE), ], - True, + {"trips": Value.TWO, "pair": Value.THREE}, ), ( [ @@ -214,7 +214,7 @@ def test_is_four_of_a_kind(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.TWO), Card(suit=Suit.CLUBS, value=Value.THREE), ], - False, + {}, ), ], ids=[ @@ -222,21 +222,22 @@ def test_is_four_of_a_kind(cards: list[Card], expected: bool) -> None: "not-full-house", ], ) -def test_is_full_house(cards: list[Card], expected: bool) -> None: - result = is_full_house(Hand(cards=cards)) +def test_is_full_house(cards: list[Card], expected: dict[str, Union[str, int]]) -> None: + result = full_house(Hand(cards=cards)) - if expected: - assert result - else: - assert not result + assert result == expected @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), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, {}), + ( + [Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], + False, + {"suit": Suit.CLUBS}, + ), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, {}), ], ids=[ "straight-only", @@ -248,12 +249,12 @@ def test_is_flush( card_factory: Factory[Card], values: list[Value], different_suits: bool, - expected: bool, + expected: dict[str, Union[str, int]], ) -> None: suits = cycle(Suit) if different_suits: - result = is_flush( + result = flush( Hand( cards=[ card_factory(suit=suit, value=value) @@ -262,20 +263,21 @@ def test_is_flush( ) ) else: - result = is_flush(Hand(cards=[card_factory(value=value) for value in values])) + result = flush(Hand(cards=[card_factory(value=value) for value in values])) - if expected: - assert result - else: - assert not result + assert result == expected @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), + ( + [Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], + True, + {"high": Value.SIX}, + ), + ([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, {}), + ([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, {}), ], ids=[ "straight-only", @@ -287,12 +289,12 @@ def test_is_straight( card_factory: Factory[Card], values: list[Value], different_suits: bool, - expected: bool, + expected: dict[str, Union[str, int]], ) -> None: suits = cycle(Suit) if different_suits: - result = is_straight( + result = straight( Hand( cards=[ card_factory(suit=suit, value=value) @@ -301,14 +303,9 @@ def test_is_straight( ) ) else: - result = is_straight( - Hand(cards=[card_factory(value=value) for value in values]) - ) + result = straight(Hand(cards=[card_factory(value=value) for value in values])) - if expected: - assert result - else: - assert not result + assert result == expected @pytest.mark.parametrize( @@ -322,7 +319,7 @@ def test_is_straight( Card(suit=Suit.SPADES, value=Value.TWO), Card(suit=Suit.SPADES, value=Value.THREE), ], - False, + {}, ), ( [ @@ -332,7 +329,7 @@ def test_is_straight( Card(suit=Suit.SPADES, value=Value.ACE), Card(suit=Suit.CLUBS, value=Value.THREE), ], - True, + {"value": Value.TWO}, ), ( [ @@ -342,18 +339,17 @@ def test_is_straight( 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)) +def test_is_three_of_a_kind( + cards: list[Card], expected: dict[str, Union[str, int]] +) -> None: + result = three_of_a_kind(Hand(cards=cards)) - if expected: - assert result - else: - assert not result + assert result == expected @pytest.mark.parametrize( @@ -367,7 +363,7 @@ def test_is_three_of_a_kind(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.THREE), Card(suit=Suit.DIAMONDS, value=Value.THREE), ], - False, + {}, ), ( [ @@ -377,7 +373,7 @@ def test_is_three_of_a_kind(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.ACE), Card(suit=Suit.CLUBS, value=Value.THREE), ], - True, + {"high": Value.ACE, "low": Value.TWO}, ), ( [ @@ -387,18 +383,15 @@ def test_is_three_of_a_kind(cards: list[Card], expected: bool) -> None: 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)) +def test_is_two_pair(cards: list[Card], expected: dict[str, Union[str, int]]) -> None: + result = two_pair(Hand(cards=cards)) - if expected: - assert result - else: - assert not result + assert result == expected @pytest.mark.parametrize( @@ -412,7 +405,7 @@ def test_is_two_pair(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.THREE), Card(suit=Suit.DIAMONDS, value=Value.THREE), ], - False, + {}, ), ( [ @@ -422,7 +415,7 @@ def test_is_two_pair(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.ACE), Card(suit=Suit.CLUBS, value=Value.THREE), ], - False, + {}, ), ( [ @@ -432,15 +425,18 @@ def test_is_two_pair(cards: list[Card], expected: bool) -> None: Card(suit=Suit.SPADES, value=Value.ACE), Card(suit=Suit.CLUBS, value=Value.THREE), ], - True, + {"value": Value.TWO}, ), ], ids=["full-house", "two-pair", "pair"], ) -def test_is_pair(cards: list[Card], expected: bool) -> None: - result = is_pair(Hand(cards=cards)) +def test_is_pair(cards: list[Card], expected: dict[str, Union[str, int]]) -> None: + result = pair(Hand(cards=cards)) - if expected: - assert result - else: - assert not result + assert result == expected + + +def test_high_card(card_factory: Factory[Card]) -> None: + values = [Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN] + result = high_card(Hand(cards=[card_factory(value=value) for value in values])) + assert result == {"value": Value.TEN}