aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Harrison <paul@harrison.sh>2022-11-18 21:00:37 +0000
committerPaul Harrison <paul@harrison.sh>2022-12-15 16:02:14 +0000
commitd924e39608861362e08c5e3706ff46a7a1af919b (patch)
tree93a3db318a4f46fed2a545afea8474f7193d7308
parent5393e7b79939f7da36d55570af3374cef2db2d51 (diff)
feat: Get hand rank
Method to get rank of a given hand.
-rw-r--r--poker/constants.py25
-rw-r--r--poker/models.py7
-rw-r--r--poker/rank/__init__.py0
-rw-r--r--poker/rank/descriptions.py0
-rw-r--r--poker/rank/hands.py110
-rw-r--r--pyproject.toml2
-rw-r--r--tests/test_rank.py446
7 files changed, 588 insertions, 2 deletions
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
--- /dev/null
+++ b/poker/rank/__init__.py
diff --git a/poker/rank/descriptions.py b/poker/rank/descriptions.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/poker/rank/descriptions.py
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