From fd3ce29712848355656e3f095ea75f80b2a6e36b Mon Sep 17 00:00:00 2001 From: jacobi petrucciani Date: Sun, 1 Sep 2019 22:00:51 -0400 Subject: [PATCH] adding hands, poker ranks, basic hand comparison, and writing more tests --- README.rst | 27 +++++- gamble/errors.py | 24 +++++ gamble/models/__init__.py | 2 +- gamble/models/cards.py | 194 +++++++++++++++++++++++++++++++++++-- gamble/models/dice.py | 12 +++ setup.py | 2 +- tests/models/test_cards.py | 64 +++++++++++- tests/models/test_dice.py | 4 + 8 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 gamble/errors.py diff --git a/README.rst b/README.rst index f7fb0a3..5fc4ee6 100644 --- a/README.rst +++ b/README.rst @@ -26,8 +26,9 @@ gamble Features -------- -- die, dice -- cards, decks +- die, dice, d-notation +- cards, decks, hands +- poker ranks, hand comparison Usage ----- @@ -104,3 +105,25 @@ Cards # the unicode cards icons are implemented as well! card.unicode >>> "🂡" + + # draw a poker hand, default size 5 + hand = deck.draw_hand(). # you can pass size=(int) to draw a different size hand + >>> + + hand.rank + >>> Rank(name='straight flush', value=8) + + # arbitrary hand, from text notation + new_hand = gamble.Hand.get("2c,3c,4c,Kc,Kh") + >>> + + new_hand.rank + >>> Rank(name='pair', value=1) + + hand > new_hand + >>> True + +Todo +---- +- hand equals/ge/le method +- hand ranking when hands are very similar diff --git a/gamble/errors.py b/gamble/errors.py new file mode 100644 index 0000000..31ee8e3 --- /dev/null +++ b/gamble/errors.py @@ -0,0 +1,24 @@ +""" +custom exceptions and error handling +""" + + +class GambleException(Exception): # pragma: no cover + """base gamble exception class""" + + def __init__(self, *args, **kwargs): # type: ignore + """exception constructor""" + self.__dict__.update(kwargs) + extra = "" + if args: + extra = '\n| extra info: "{extra}"'.format(extra=args[0]) + print( + "[{exception}]: {doc}{extra}".format( + exception=self.__class__.__name__, doc=self.__doc__, extra=extra + ) + ) + Exception.__init__(self, *args) + + +class InvalidCard(GambleException): + """the given string is not a valid card""" diff --git a/gamble/models/__init__.py b/gamble/models/__init__.py index ebff372..6ef7116 100644 --- a/gamble/models/__init__.py +++ b/gamble/models/__init__.py @@ -2,4 +2,4 @@ models submodule for gamble """ from gamble.models.dice import Die, Dice # noqa -from gamble.models.cards import Card, Deck, EuchreDeck # noqa +from gamble.models.cards import Card, Deck, EuchreDeck, Hand # noqa diff --git a/gamble/models/cards.py b/gamble/models/cards.py index d4bb841..8b39bcf 100644 --- a/gamble/models/cards.py +++ b/gamble/models/cards.py @@ -2,8 +2,10 @@ standard deck of cards submodule """ import random +from collections import Counter from types import SimpleNamespace -from typing import Any, List, Union +from typing import Any, Dict, List, Union +from gamble.errors import InvalidCard class Suit(SimpleNamespace): @@ -14,6 +16,10 @@ class Value(SimpleNamespace): """value namespace class""" +class Rank(SimpleNamespace): + """hand ranks namespace class""" + + class Card: """playing card model""" @@ -23,10 +29,18 @@ class Card: class Suits: """card suit enum""" - SPADES = Suit(name="spades", char="♠", value=0, color=0, unicode=127136) - CLUBS = Suit(name="clubs", char="♣", value=1, color=0, unicode=127184) - DIAMONDS = Suit(name="diamonds", char="♦", value=2, color=1, unicode=127168) - HEARTS = Suit(name="hearts", char="♥", value=3, color=1, unicode=127152) + SPADES = Suit( + name="spades", char="S", symbol="♠", value=0, color=0, unicode=127136 + ) + CLUBS = Suit( + name="clubs", char="C", symbol="♣", value=1, color=0, unicode=127184 + ) + DIAMONDS = Suit( + name="diamonds", char="D", symbol="♦", value=2, color=1, unicode=127168 + ) + HEARTS = Suit( + name="hearts", char="H", symbol="♥", value=3, color=1, unicode=127152 + ) @classmethod def all(cls) -> List[Suit]: @@ -40,6 +54,11 @@ def all(cls) -> List[Suit]: key=lambda x: x.value, ) + @classmethod + def dict(cls) -> Dict[str, Suit]: + """dict of char -> Suit""" + return {x.char: x for x in cls.all()} + class Values: """card value enum""" @@ -69,11 +88,30 @@ def all(cls) -> List[Value]: key=lambda x: x.value, ) + @classmethod + def dict(cls) -> Dict[str, Value]: + """dict of char -> Value""" + return {x.char: x for x in cls.all()} + def __init__(self, value: Value = Values.ACE, suit: Suit = Suits.SPADES) -> None: """card constructor""" self.value = value self.suit = suit + @classmethod + def get(cls, text: str) -> "Card": + """get a card by text representation""" + if not len(text) == 2: + raise InvalidCard("Too many characters for a card!") + vals = cls.Values.dict() + suits = cls.Suits.dict() + value_char, suit_char = list(text.upper()) + if value_char not in vals: + raise InvalidCard("Invalid value for card!") + if suit_char not in suits: + raise InvalidCard("Invalid suit for card!") + return cls(value=vals[value_char], suit=suits[suit_char]) + @property def color(self) -> int: """returns the color of the card""" @@ -103,7 +141,7 @@ def is_red(self) -> bool: def __str__(self) -> str: """string representation of this card""" - return "{}{}".format(self.value.char, self.suit.char) + return "{}{}".format(self.value.char, self.suit.symbol) def __repr__(self) -> str: """representation of this card""" @@ -113,6 +151,18 @@ def __lt__(self, other: "Card") -> bool: """less than dunder method""" return self.value.value < other.value.value + def __gt__(self, other: "Card") -> bool: + """greater than dunder method""" + return self.value.value > other.value.value + + def __le__(self, other: "Card") -> bool: + """less than or equal to dunder method""" + return self < other or self == other + + def __ge__(self, other: "Card") -> bool: + """greater than or equal to dunder method""" + return self > other or self == other + def __eq__(self, other: object) -> bool: """equal to dunder method""" if not isinstance(other, Card): @@ -120,6 +170,133 @@ def __eq__(self, other: object) -> bool: return self.suit == other.suit and self.value == other.value +class Hand: + """playing card hand model""" + + class Ranks: + """hand ranks for poker""" + + STRAIGHT_FLUSH = Rank(value=8, name="straight flush") + FOUR_OF_A_KIND = Rank(value=7, name="four of a kind") + FULL_HOUSE = Rank(value=6, name="full house") + FLUSH = Rank(value=5, name="flush") + STRAIGHT = Rank(value=4, name="straight") + THREE_OF_A_KIND = Rank(value=3, name="three of a kind") + TWO_PAIR = Rank(value=2, name="two pair") + PAIR = Rank(value=1, name="pair") + HIGH_CARD = Rank(value=0, name="high card") + + def __init__(self, cards: List[Card]) -> None: + """hand constructor""" + self._cards = cards + self.cards = sorted(self._cards) + self.size = len(self.cards) + self.value_counts = Counter([x.value.value for x in self.cards]) + self.suit_counts = Counter([x.suit.value for x in self.cards]) + + def __lt__(self, other: "Hand") -> bool: + """less than dunder method""" + return self.rank.value < other.rank.value + + def __gt__(self, other: "Hand") -> bool: + """greater than dunder method""" + return self.rank.value > other.rank.value + + def __len__(self) -> int: + """dunder len method""" + return len(self.cards) + + def __str__(self) -> str: + """string representation of the hand""" + return "[{}]".format(", ".join([str(x) for x in self.cards])) + + def __repr__(self) -> str: + """repr of the hand""" + return "".format(self.size, self.rank.name, str(self)) + + @classmethod + def get(cls, text: str) -> "Hand": + """get a hand by text representations""" + card_strings = text.replace(" ", "").upper().split(",") + cards = [Card.get(x) for x in card_strings] + return cls(cards=cards) + + @property + def rank(self) -> Rank: + """get the rank of this hand""" + if self.is_straight_flush: + return Hand.Ranks.STRAIGHT_FLUSH + if self.is_four_of_a_kind: + return Hand.Ranks.FOUR_OF_A_KIND + if self.is_full_house: + return Hand.Ranks.FULL_HOUSE + if self.is_flush: + return Hand.Ranks.FLUSH + if self.is_straight: + return Hand.Ranks.STRAIGHT + if self.is_three_of_a_kind: + return Hand.Ranks.THREE_OF_A_KIND + if self.is_two_pair: + return Hand.Ranks.TWO_PAIR + if self.is_one_pair: + return Hand.Ranks.PAIR + return Hand.Ranks.HIGH_CARD + + @property + def _vals(self) -> List[int]: + """values helper to make the following checks less verbose""" + return sorted(list(self.value_counts.values()), reverse=True) + + @property + def is_straight_flush(self) -> bool: + """check if the hand is a straight flush""" + return self.is_flush and self.is_straight + + @property + def is_four_of_a_kind(self) -> bool: + """check if the hand is four of a kind""" + return self._vals[0] == 4 + + @property + def is_full_house(self) -> bool: + """check if the hand is a full house""" + return self._vals[0:2] == [3, 2] + + @property + def is_flush(self) -> bool: + """check if the hand is a flush""" + return len(set([x.suit.value for x in self.cards])) == 1 + + @property + def is_straight(self) -> bool: + """check if the hand is a straight""" + + def check(value_set: set) -> bool: + """check if the given set is a straight""" + value_range = max(value_set) - min(value_set) + return (value_range == self.size - 1) and (len(value_set) == self.size) + + values = [x.value.value for x in self.cards] + low_ace = set(values) + high_ace = set(x if x != 1 else 14 for x in values) + return check(low_ace) or check(high_ace) + + @property + def is_three_of_a_kind(self) -> bool: + """check if the hand is three of a kind""" + return self._vals[0] == 3 + + @property + def is_two_pair(self) -> bool: + """check if the hand contains two pair""" + return self._vals[0:2] == [2, 2] + + @property + def is_one_pair(self) -> bool: + """check if the hand contains one pair""" + return self._vals[0] == 2 + + class Deck: """playing card deck model""" @@ -179,6 +356,11 @@ def draw(self, times: int = 1) -> Union[Card, List[Card]]: cards.append(self.cards.pop()) return cards + def draw_hand(self, size: int = 5) -> Hand: + """draw a hand from this deck""" + cards = self.draw(times=size) + return Hand(cards=cards if isinstance(cards, list) else [cards]) + def shuffle(self, times: int = 1) -> None: """shuffle the deck""" for _ in range(times): diff --git a/gamble/models/dice.py b/gamble/models/dice.py index d0d51b0..bfe1d24 100644 --- a/gamble/models/dice.py +++ b/gamble/models/dice.py @@ -29,6 +29,18 @@ def __lt__(self, other: "Die") -> bool: """less than dunder method""" return self.net_sides < other.net_sides + def __gt__(self, other: "Die") -> bool: + """greater than dunder method""" + return self.net_sides > other.net_sides + + def __le__(self, other: "Die") -> bool: + """less than or equal to dunder method""" + return self < other or self == other + + def __ge__(self, other: "Die") -> bool: + """greater than or equal to dunder method""" + return self > other or self == other + @property def net_sides(self) -> int: """the raw max sides * multiplier""" diff --git a/setup.py b/setup.py index 438c713..1b8211f 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ __library__ = "gamble" -__version__ = "0.0.5" +__version__ = "0.0.6" with open("README.rst") as readme: LONG_DESCRIPTION = readme.read() diff --git a/tests/models/test_cards.py b/tests/models/test_cards.py index ed31788..6f5aaf5 100644 --- a/tests/models/test_cards.py +++ b/tests/models/test_cards.py @@ -1,7 +1,9 @@ """ tests for the cards submodule of gamble """ -from gamble import Card, Deck, EuchreDeck +import pytest +from gamble import Card, Deck, EuchreDeck, Hand +from gamble.errors import InvalidCard def test_card_init() -> None: @@ -16,9 +18,21 @@ def test_card_init() -> None: assert card == card assert card != "test" assert not card < card + assert not card > card + assert card <= card + assert card >= card assert card.is_black assert not card.is_red + with pytest.raises(InvalidCard): + Card.get("XXX") + + with pytest.raises(InvalidCard): + Card.get("ZS") + + with pytest.raises(InvalidCard): + Card.get("AZ") + # check that the unicode lookup works seven = Card(value=Card.Values.SEVEN, suit=Card.Suits.DIAMONDS) assert seven @@ -62,3 +76,51 @@ def test_euchre_deck() -> None: """tests a euchre specific deck""" deck = EuchreDeck(shuffle=False) assert deck.cards_left == 24 + + +def test_hands() -> None: + """test that we can interact with hands""" + deck = Deck(shuffle=False) + hand = deck.draw_hand() + assert len(hand) == 5 + assert str(hand) == "[A♠, 2♠, 3♠, 4♠, 5♠]" + assert repr(hand) == "" + + +def test_hand_ranks() -> None: + """test all the supported hand ranks""" + high_card = Hand.get("2c,3c,4c,5c,Kh") + assert high_card.rank == Hand.Ranks.HIGH_CARD + + one_pair = Hand.get("2c,3c,4c,Kc,Kh") + assert one_pair.rank == Hand.Ranks.PAIR + + two_pair = Hand.get("2c,4h,4c,Kc,Kh") + assert two_pair.rank == Hand.Ranks.TWO_PAIR + + three_of_a_kind = Hand.get("2c,4h,Ks,Kc,Kh") + assert three_of_a_kind.rank == Hand.Ranks.THREE_OF_A_KIND + + low_straight = Hand.get("2c,3h,4c,5c,Ah") + assert low_straight.rank == Hand.Ranks.STRAIGHT + + high_straight = Hand.get("Tc,Jh,Qc,Kc,Ah") + assert high_straight.rank == Hand.Ranks.STRAIGHT + + flush = Hand.get("2c,3c,4c,5c,Kc") + assert flush.rank == Hand.Ranks.FLUSH + + full_house = Hand.get("2c,2s,2h,Ks,Kc") + assert full_house.rank == Hand.Ranks.FULL_HOUSE + + four_of_a_kind = Hand.get("2c,2s,2h,2d,Kc") + assert four_of_a_kind.rank == Hand.Ranks.FOUR_OF_A_KIND + + low_straight_flush = Hand.get("As,2s,3s,4s,5s") + assert low_straight_flush.rank == Hand.Ranks.STRAIGHT_FLUSH + + high_straight_flush = Hand.get("As,Ts,Js,Qs,Ks") + assert high_straight_flush.rank == Hand.Ranks.STRAIGHT_FLUSH + + assert two_pair > one_pair + assert high_card < high_straight_flush diff --git a/tests/models/test_dice.py b/tests/models/test_dice.py index 042a8f9..3da1c4c 100644 --- a/tests/models/test_dice.py +++ b/tests/models/test_dice.py @@ -15,6 +15,10 @@ def test_die_init() -> None: assert die.net_sides == die.sides assert str(die) == "" assert repr(die) == "" + assert not die > die + assert not die < die + assert die >= die + assert die <= die def test_dice_init() -> None: