"""Classes and functions to evaluate poker hands"""
import functools
import itertools
import operator
from timeit import default_timer as timer
from typing import Dict, Iterator, List, Tuple
from clubs import error
from . import card
[docs]class Evaluator(object):
"""Evalutes poker hands using hole and community cards
Parameters
----------
suits : int
number of suits in deck
ranks : int
number of ranks in deck
cards_for_hand : int
number of cards used for valid poker hand
mandatory_hole_cards : int, optional
number of hole cards which must be used for a hands, by default
0
low_end_straight : bool, optional
toggle to include the low ace straight within valid hands, by
default True
order : list, optional
optional custom order of hand ranks, must be permutation of
['sf', 'fk', 'fh', 'fl', 'st', 'tk', 'tp', 'pa', 'hc']. if
order=None, hands are ranked by rarity. by default None
"""
def __init__(
self,
suits: int,
ranks: int,
cards_for_hand: int,
mandatory_hole_cards: int = 0,
low_end_straight: bool = True,
order: list = None,
):
if cards_for_hand < 1 or cards_for_hand > 5:
raise error.InvalidHandSizeError(
f"Evaluation for {cards_for_hand} "
f"card hands is not supported. "
f"clubs currently supports 1-5 card poker hands"
)
self.suits = suits
self.ranks = ranks
self.cards_for_hand = cards_for_hand
self.mandatory_hole_cards = mandatory_hole_cards
self.table = LookupTable(
suits, ranks, cards_for_hand, low_end_straight=low_end_straight, order=order
)
total = sum(
self.table.hand_dict[hand]["suited"] for hand in self.table.ranked_hands
)
hands = [
"{} ({:.4%})".format(hand, self.table.hand_dict[hand]["suited"] / total)
for hand in self.table.ranked_hands
]
self.hand_ranks = " > ".join(hands)
def __str__(self) -> str:
return self.hand_ranks
def __repr__(self) -> str:
return f"Evaluator ({id(self)}): {str(self)}"
[docs] def evaluate(
self, hole_cards: List[card.Card], community_cards: List[card.Card]
) -> int:
"""Evaluates the hand rank of a poker hand from a list of hole
and a list of community cards. Empty hole and community cards
are supported as well as requiring a minimum number of hole
cards to be used.
Parameters
----------
hole_cards : List[card.Card]
list of hole cards
community_cards : List[card.Card]
list of community cards
Returns
-------
int
hand rank
"""
# if a number of hole cards are mandatory
if self.mandatory_hole_cards:
# get all hole and community card combinations
hole_card_combs = itertools.combinations(
hole_cards, self.mandatory_hole_cards
)
num_comm_cards = self.cards_for_hand - self.mandatory_hole_cards
if num_comm_cards:
comm_card_combs = itertools.combinations(
community_cards, num_comm_cards
)
iterator = itertools.product(hole_card_combs, comm_card_combs)
all_card_combs = list((sum(card_comb, ()) for card_comb in iterator))
else:
all_card_combs = list(hole_card_combs)
# else create combinations from all cards
else:
all_card_combs = list(
itertools.combinations(
hole_cards + community_cards, self.cards_for_hand
)
)
minimum = self.table.max_rank
for card_comb in all_card_combs:
score = self.table.lookup(list(card_comb))
if score < minimum:
minimum = score
return minimum
[docs] def get_rank_class(self, hand_rank: int) -> str:
"""Outputs hand rank string from integer hand rank
Parameters
----------
hand_rank : int
hand_rank (int): integer hand rank
Returns
-------
str
hand rank string
"""
if hand_rank < 0:
raise error.InvalidHandRankError(
(
f"invalid hand rank, expected 0 <= hand_rank"
f" <= {self.table.max_rank}, got {hand_rank}"
)
)
for hand in self.table.ranked_hands:
if hand_rank <= self.table.hand_dict[hand]["cumulative unsuited"]:
return hand
raise error.InvalidHandRankError(
(
f"invalid hand rank, expected 0 <= hand_rank"
f" <= {self.table.max_rank}, got {hand_rank}"
)
)
[docs] def speed_test(self, n: int = 100000) -> float:
"""Tests speed of evaluator
Parameters
----------
n : int, optional
number of iterations/hands to test, by default 100000
Returns
-------
float
average time per hand evaluation
"""
deck = card.Deck(self.suits, self.ranks)
time = 0.0
for _ in range(n):
start = timer()
self.evaluate(deck.draw(2), deck.draw(5))
time += timer() - start
avg = time / n
return avg
[docs]class LookupTable:
"""Lookup table maps unique prime product of hands to unique
integer hand rank. The lower the rank the better the hand
Parameters
----------
suits : int
number of suits in deck
ranks : int
number of ranks in deck
cards_for_hand : int
number of cards used for a poker hand
low_end_straight : bool, optional
toggle to include straights where ace is the lowest card, by
default True
order : List[str], optional
custom hand rank order, if None hands are ranked by rarity, by
default None
"""
ORDER_STRINGS = ["sf", "fk", "fh", "fl", "st", "tk", "tp", "pa", "hc"]
def __init__(
self,
suits: int,
ranks: int,
cards_for_hand: int,
low_end_straight: bool = True,
order: List[str] = None,
):
if order is not None:
if any(string not in order for string in self.ORDER_STRINGS):
raise error.InvalidOrderError(
(
f"invalid order list {order},"
f"order list must be permutation of {self.ORDER_STRINGS}"
)
)
# number of suited and unsuited possibilities of different hands
straight_flushes, u_straight_flushes = self._straight_flush(
suits, ranks, cards_for_hand, low_end_straight
)
four_of_a_kinds, u_four_of_a_kinds = self._four_of_a_kind(
suits, ranks, cards_for_hand
)
full_houses, u_full_houses = self._full_house(suits, ranks, cards_for_hand)
flushes, u_flushes = self._flush(suits, ranks, cards_for_hand, low_end_straight)
straights, u_straights = self._straight(
suits, ranks, cards_for_hand, low_end_straight
)
three_of_a_kinds, u_three_of_a_kinds = self._three_of_a_kind(
suits, ranks, cards_for_hand
)
two_pairs, u_two_pairs = self._two_pair(suits, ranks, cards_for_hand)
pairs, u_pairs = self._pair(suits, ranks, cards_for_hand)
high_cards, u_high_cards = self._high_card(
suits, ranks, cards_for_hand, low_end_straight
)
self.hand_dict = {
"straight flush": {
"suited": straight_flushes,
"unsuited": u_straight_flushes,
},
"four of a kind": {
"suited": four_of_a_kinds,
"unsuited": u_four_of_a_kinds,
},
"full house": {"suited": full_houses, "unsuited": u_full_houses},
"flush": {"suited": flushes, "unsuited": u_flushes},
"straight": {"suited": straights, "unsuited": u_straights},
"three of a kind": {
"suited": three_of_a_kinds,
"unsuited": u_three_of_a_kinds,
},
"two pair": {"suited": two_pairs, "unsuited": u_two_pairs},
"pair": {"suited": pairs, "unsuited": u_pairs},
"high card": {"suited": high_cards, "unsuited": u_high_cards},
}
# suited hands
s_hands = [
(straight_flushes, "straight flush"),
(four_of_a_kinds, "four of a kind"),
(full_houses, "full house"),
(flushes, "flush"),
(straights, "straight"),
(three_of_a_kinds, "three of a kind"),
(two_pairs, "two pair"),
(pairs, "pair"),
(high_cards, "high card"),
]
# sort suited hands and rank unsuited hands by suited
# rank order or by order provided
if order is None:
s_hands = sorted(s_hands)
else:
idcs = [self.ORDER_STRINGS.index(hand) for hand in order]
s_hands = [s_hands[idx] for idx in idcs]
# lookup is done on unsuited hands but hand
# rank is dependent on suited hands
u_hands = [
(self.hand_dict[u_hand[1]]["unsuited"], u_hand[1]) for u_hand in s_hands
]
# compute cumulative number of unsuited hands for each hand
# cumulative unsuited is the maximum rank a hand can have
ranked_hands = []
rank = 0
cumulative_hands = 0
for u_hand in u_hands:
hand_rank = u_hand[1]
cumulative_hands += u_hand[0]
self.hand_dict[hand_rank]["cumulative unsuited"] = cumulative_hands
if cumulative_hands > 0:
self.hand_dict[hand_rank]["rank"] = rank
rank += 1
ranked_hands.append(hand_rank)
self.max_rank = cumulative_hands
# list of hands ordered by rank from best to worst
self.ranked_hands = ranked_hands
# create lookup tables
suited_lookup, unsuited_lookup = self._flushes(
ranks, cards_for_hand, low_end_straight
)
unsuited_lookup = self._multiples(ranks, cards_for_hand, unsuited_lookup)
# if suited hands aren't relevant set the suited
# lookup table equal to the unsuited table
if not self.hand_dict["flush"]["cumulative unsuited"]:
suited_lookup = unsuited_lookup
self.suited_lookup = suited_lookup
self.unsuited_lookup = unsuited_lookup
[docs] def lookup(self, cards: List[card.Card]) -> int:
"""Return unique hand rank for list of cards
Parameters
----------
cards : List[card.Card]
list of cards to be evaluated
Returns
-------
int
hand rank
"""
# if all flush bits equal then use flush lookup
if functools.reduce(operator.and_, cards, 0xF000):
hand_or = functools.reduce(operator.or_, cards) >> 16
prime = _prime_product_from_rank_bits(hand_or)
return self.suited_lookup[prime]
prime = _prime_product_from_hand(cards)
return self.unsuited_lookup[prime]
@staticmethod
def _straight_flush(
suits: int, ranks: int, cards_for_hand: int, low_end_straight: bool
) -> Tuple[int, int]:
if cards_for_hand < 3 or suits < 2:
return 0, 0
# number of smallest cards which start straight
unsuited = ranks - (cards_for_hand - 1) + low_end_straight
# multiplied with number of suits
suited = max(unsuited, unsuited * suits)
return int(suited), int(unsuited)
@staticmethod
def _four_of_a_kind(suits: int, ranks: int, cards_for_hand: int) -> Tuple[int, int]:
if cards_for_hand < 4 or suits < 4:
return 0, 0
# choose 1 rank for quads multiplied by
# rank choice for remaining cards
unsuited = _ncr(ranks, 1) * _ncr(ranks - 1, cards_for_hand - 4)
# mutliplied with number of suit choices for remaining cards
suited = max(unsuited, unsuited * suits ** (cards_for_hand - 4))
return int(suited), int(unsuited)
@staticmethod
def _full_house(suits: int, ranks: int, cards_for_hand: int) -> Tuple[int, int]:
if cards_for_hand < 5 or suits < 3:
return 0, 0
# choose one rank for trips and pair multiplied by
# rank choice for remaining cards
unsuited = (
_ncr(ranks, 1) * _ncr(ranks - 1, 1) * _ncr(ranks - 2, cards_for_hand - 5)
)
# multiplied with number of suit choices for
# trips + pair and remaining cards
suited = max(
unsuited,
unsuited * _ncr(suits, 3) * _ncr(suits, 2) * suits ** (cards_for_hand - 5),
)
return int(suited), int(unsuited)
@staticmethod
def _flush(
suits: int, ranks: int, cards_for_hand: int, low_end_straight: bool
) -> Tuple[int, int]:
if cards_for_hand < 3 or suits < 2:
return 0, 0
# all straight combinations
straight_flushes = ranks - (cards_for_hand - 1) + low_end_straight
# choose all cards from ranks minus straight flushes
unsuited = _ncr(ranks, cards_for_hand) - straight_flushes
# multiplied by number of suits
suited = max(unsuited, unsuited * suits)
return int(suited), int(unsuited)
@staticmethod
def _straight(
suits: int, ranks: int, cards_for_hand: int, low_end_straight: bool
) -> Tuple[int, int]:
if cards_for_hand < 3:
return 0, 0
# number of smallest cards which start straight
unsuited = ranks - (cards_for_hand - 1) + low_end_straight
# straight flush combinations
straight_flushes = 0
if suits > 1:
straight_flushes = unsuited * suits
# multiplied with suit choice for every card
# minus straight flushes
suited = max(unsuited, unsuited * suits ** cards_for_hand - straight_flushes)
if suits < 2:
suited = unsuited
return int(suited), int(unsuited)
@staticmethod
def _three_of_a_kind(
suits: int, ranks: int, cards_for_hand: int
) -> Tuple[int, int]:
if cards_for_hand < 3 or suits < 3:
return 0, 0
# choose one rank for trips multiplied by
# rank choice for remaining cards
unsuited = _ncr(ranks, 1) * _ncr(ranks - 1, cards_for_hand - 3)
# multiplied with suit choices for trips and remaining cards
suited = max(
unsuited, unsuited * _ncr(suits, 3) * _ncr(suits, 3) ** (cards_for_hand - 3)
)
return int(suited), int(unsuited)
@staticmethod
def _two_pair(suits: int, ranks: int, cards_for_hand: int) -> Tuple[int, int]:
if cards_for_hand < 4 or suits < 2:
return 0, 0
# choose two ranks for pairs multiplied by
# ranks for remaining cards
unsuited = _ncr(ranks, 2) * _ncr(ranks - 2, cards_for_hand - 4)
# multiplied with suit choices for both pairs
# and suit choices for remaining cards
suited = max(
unsuited, unsuited * _ncr(suits, 2) ** 2 * suits ** (cards_for_hand - 4)
)
return int(suited), int(unsuited)
@staticmethod
def _pair(suits: int, ranks: int, cards_for_hand: int) -> Tuple[int, int]:
if cards_for_hand < 2 or suits < 2:
return 0, 0
# choose rank for pair multiplied by
# ranks for remaining cards
unsuited = _ncr(ranks, 1) * _ncr(ranks - 1, cards_for_hand - 2)
# multiplied with suit choices for pair and remaining cards
suited = max(
unsuited, unsuited * _ncr(suits, 2) * suits ** (cards_for_hand - 2)
)
return int(suited), int(unsuited)
@staticmethod
def _high_card(
suits: int, ranks: int, cards_for_hand: int, low_end_straight: bool
) -> Tuple[int, int]:
# number of smallest cards which start straight
straights = 0
if cards_for_hand > 2:
straights = ranks - (cards_for_hand - 1) + low_end_straight
# any combination of rank and subtract straights
unsuited = _ncr(ranks, cards_for_hand) - straights
# multiplied with suit choices for all cards
# all same suits not allowed
suited = max(unsuited, unsuited * (suits ** cards_for_hand - suits))
if suits < 2:
suited = unsuited
return int(suited), int(unsuited)
@staticmethod
def _gen_straight_flush(
cards_for_hand: int, ranks: int, low_end_straight: bool
) -> List[int]:
straight_flushes = []
# start with best straight (flush)
# for 5 card hand with 13 ranks: 0b1111100000000
bin_num_str = "0b" + "1" * cards_for_hand + "0" * (13 - cards_for_hand)
# remove one 0 for every straight (flush)
for _ in range(ranks - (cards_for_hand - 1)):
straight_flushes.append(int(bin_num_str, 2))
bin_num_str = bin_num_str[:-1]
if low_end_straight:
# add low end straight
bin_num_str = (
"0b1"
+ "0" * (ranks - cards_for_hand)
+ "1" * (cards_for_hand - 1)
+ "0" * (13 - ranks)
)
straight_flushes.append(int(bin_num_str, 2))
return straight_flushes
@staticmethod
def _gen_flush(
cards_for_hand: int, ranks: int, straight_flushes: List[int]
) -> List[int]:
flushes = []
# start with lowest non pair hand
# for 5 card hand with 13 ranks: 0b11111
bin_num_str = "0b" + ("1" * cards_for_hand)
gen = _lexographic_next_bit(int(bin_num_str, 2))
# iterate over all possibilities of unique hands
for _ in range(int(_ncr(ranks, cards_for_hand))):
# pull the next flush pattern from generator
# offset by number of ranks not in play
flush = next(gen) << (13 - ranks)
if flush not in straight_flushes:
flushes.append(flush)
flushes.reverse()
return flushes
def _flushes(
self, ranks: int, cards_for_hand: int, low_end_straight: bool
) -> Tuple[Dict[int, int], Dict[int, int]]:
straight_flushes = []
if (
self.hand_dict["straight flush"]["cumulative unsuited"]
or self.hand_dict["straight"]["cumulative unsuited"]
):
straight_flushes = self._gen_straight_flush(
cards_for_hand, ranks, low_end_straight
)
# dynamically generate all the other
# flushes (including straight flushes)
flushes = []
if (
self.hand_dict["flush"]["cumulative unsuited"]
or self.hand_dict["high card"]["cumulative unsuited"]
):
flushes = self._gen_flush(cards_for_hand, ranks, straight_flushes)
def add_to_dict(
rank_string: str, rank_bits: List[int], lookup: Dict[int, int],
) -> Dict[int, int]:
if not self.hand_dict[rank_string]["cumulative unsuited"]:
return lookup
num_ranks = len(rank_bits)
assert num_ranks == self.hand_dict[rank_string]["unsuited"]
hand_rank = self._get_rank(rank_string)
for _rank_bits in rank_bits:
prime_product = _prime_product_from_rank_bits(_rank_bits)
lookup[prime_product] = hand_rank
hand_rank += 1
return lookup
suited_lookup: Dict[int, int] = {}
unsuited_lookup: Dict[int, int] = {}
suited_lookup = add_to_dict("straight flush", straight_flushes, suited_lookup)
suited_lookup = add_to_dict("flush", flushes, suited_lookup)
unsuited_lookup = add_to_dict("straight", straight_flushes, unsuited_lookup)
unsuited_lookup = add_to_dict("high card", flushes, unsuited_lookup)
return suited_lookup, unsuited_lookup
def _multiples(
self, ranks: int, cards_for_hand: int, unsuited_lookup: Dict[int, int],
) -> Dict[int, int]:
def add_to_dict(
rank_string: str, multiples: List[int], unsuited_lookup: Dict[int, int],
) -> Dict[int, int]:
# inverse ranks, A - 2
backwards_ranks = list(range(13 - 1, 13 - 1 - ranks, -1))
if not self.hand_dict[rank_string]["cumulative unsuited"]:
return unsuited_lookup
# get cumulative hand rank
hand_rank = self._get_rank(rank_string)
# if different multiples (e.g. full house) order of
# multiples is important
if len(set(multiples)) > 1:
multiple_combinations = itertools.permutations(
backwards_ranks, len(multiples)
)
# if same multiples (e.g. two pair) order of multiples
# is unimportant
else:
multiple_combinations = itertools.combinations(
backwards_ranks, len(multiples)
)
for card_ranks in multiple_combinations:
base_product = 1
# compute product over every combination of multiple
# card rank
for card_rank, multiple in zip(card_ranks, multiples):
base_product *= card.PRIMES[card_rank] ** multiple
num_kickers = cards_for_hand - sum(multiples)
# record product in lookup table
if num_kickers:
kickers = backwards_ranks[:]
for card_rank in card_ranks:
kickers.remove(card_rank)
kicker_combinations = list(
itertools.combinations(kickers, num_kickers)
)
for kickers_tuple in kicker_combinations:
product = base_product
for kicker in kickers_tuple:
product *= card.PRIMES[kicker]
unsuited_lookup[product] = hand_rank
hand_rank += 1
else:
unsuited_lookup[base_product] = hand_rank
hand_rank += 1
# check hand rank is equal to number of iterated ranks
num_ranks = hand_rank - self._get_rank(rank_string)
assert num_ranks == self.hand_dict[rank_string]["unsuited"]
return unsuited_lookup
add_to_dict("four of a kind", [4], unsuited_lookup)
add_to_dict("full house", [3, 2], unsuited_lookup)
add_to_dict("three of a kind", [3], unsuited_lookup)
add_to_dict("two pair", [2, 2], unsuited_lookup)
add_to_dict("pair", [2], unsuited_lookup)
return unsuited_lookup
def _get_rank(self, hand: str) -> int:
rank = self.hand_dict[hand]["rank"]
if not rank:
return 0
better_hand = self.ranked_hands[rank - 1]
return self.hand_dict[better_hand]["cumulative unsuited"] + 1
def _prime_product_from_rank_bits(rankbits: int) -> int:
product = 1
for i in card.INT_RANKS:
# if the ith bit is set
if rankbits & (1 << i):
product *= card.PRIMES[i]
return product
def _prime_product_from_hand(cards: List[card.Card]) -> int:
product = 1
for _card in cards:
product *= _card & 0xFF
return product
def _lexographic_next_bit(bits: int) -> Iterator[int]:
# generator next legographic bit sequence given a bit sequence with
# N bits set e.g.
# 00010011 -> 00010101 -> 00010110 -> 00011001 ->
# 00011010 -> 00011100 -> 00100011 -> 00100101
lex = bits
yield lex
while True:
temp = (lex | (lex - 1)) + 1
lex = temp | ((((temp & -temp) // (lex & -lex)) >> 1) - 1)
yield lex
def _ncr(n: int, r: int) -> int:
r = min(r, n - r)
numer = functools.reduce(operator.mul, range(n, n - r, -1), 1)
denom = functools.reduce(operator.mul, range(1, r + 1), 1)
return numer // denom