c1Card(suit='♠', rank='10')
TODO:
The goal is to create a minimum viable product. Focus on finishing basic common rules.
Try class for Card later? namedtuple works for now.
Explore ways to design Player and Round classes.
Try IntEnum for Poker hand rankings. Currently, we use integer values from 14 to 1 to indicate the poker hand rankings. Using IntEnum may make it more readable. Read more about enum: https://docs.python.org/3/library/enum.html
Try passing Round to Player when taking action to update stage_bet.
Player should not be able to check if stage_bet != player.bet.
Odd chips vanish — self.pot // len(winners) drops the remainder
Implement the rule: If everyone folds except for one player, that player wins
Try rewriting evaluate_hand lazily.
Faster equity
Implement all-in
Rather than printing, maybe add note messages?
Add GUI with fasthtml
Add possible actions for players. (maybe with GUI?)
Deploy on server
In this dialog, I want to go through the process of writing a poker program myself but following the approach I learned in the cs212 class. My goal is not to copy everything from the lesson exactly, but follow the steps: understanding the problem, specifying, concept inventory, etc. that I can apply to solving any problem. To challenge myself, I will write texas hold’em poker.
Here’s the process Norvig taught:
1. Understand — Start with a vague understanding and refine it. Make an inventory of all the concepts you’ll need to deal with (data types, operations, etc.).
2. Specify — Define what needs to happen: inputs, outputs, what each function should accomplish. Think of it as a contract — not the implementation details, just the “what.”
3. Design — Figure out how to make it happen — the actual algorithms, code structure, and implementation.
Along the way, key principles: - Write tests early (even before implementation), including extreme values - Reuse existing tools when possible - Refactor for elegance (DRY, clarity, simplicity, generality) - Be aware of the four dimensions: correctness, efficiency, features, elegance — and make conscious tradeoffs
namedtuple with patch
Let’s use emoji for suits and string of alphanumerics for ranks.
Rather than using a string to represent cards and using separate functions to access suits and ranks, I thought it would be better to use the . (dot) notation. One way to accomplish this is through class, but I wanted to try using namedtuple, which allows me to use attribute access and immutability. The ranks and suits from each card should not change. It is more lightweight than class.
I wanted the card representation to be more concise, so I used fastcore’s patch for __repr__.
Return repr(self).
Currently, we cannot compare the ranks directly as we are using string comparisons lexicographically.
It is convenient to sort hands by rank values when evaluating hands.
Using rank_values mapping, we can convert the string ranks into integers. From 2 to 10, their values are 2 to 10 respectively. Then, from ‘J’ to ‘A’, 11 to 14.
Although I had sort_cards function that sorts cards by their rank_values from the highest, it was more convenient to patch __lt__ and __eq__ and just use default sorted function. When comparing cards, we only care about their ranks, not suits.
Return self<value.
Return self==value.
evaluate_hand(cards) -> HandRank — given 7 cards, returns the best 5-card HandRankcompare_ranks(ranks) -> tuple — returns the winner rankA hand has two cards. Community cards have five cards.
Poker hands: - royal flush (10) - straight flush (9, highest_value) - four of a kind (8, highest_value, kicker) - full house (7, triple, pair) - flush (6, five kickers) - straight (5, highest_value) - three of a kind (4, triple_value, two kickers) - two pair (3, high_pair, low_pair, kicker) - one pair (2, pair_value, three kickers) - high card (1, highest, second highest, third, fourth, fifth)
These are some example hands used for examples and tests.
[♠2, ♥4, ♦6, ♣8, ♠10, ♥Q, ♦A]
[♠2, ♥2, ♠5, ♥7, ♦9, ♣J, ♠K]
[♠2, ♥2, ♦2, ♠5, ♥7, ♦9, ♣J]
[♠2, ♥2, ♦2, ♣2, ♠6, ♥9, ♦Q]
[♠2, ♥2, ♦3, ♣3, ♠6, ♥8, ♦10]
[♠2, ♥2, ♠3, ♦3, ♣3, ♥8, ♦10]
[♠3, ♥4, ♦5, ♣6, ♠7, ♥10, ♦Q]
[♠2, ♠4, ♠6, ♠8, ♠10, ♥5, ♦9]
[♠3, ♠4, ♠5, ♠6, ♠7, ♥10, ♦Q]
By getting the counts from the cards, we can figure out four of a kind, full house, three of a kind, two pair, and one pair. One thing we have to be careful of is because we have seven cards to evaluate, full house might have two three of kinds, rather than a three of kind and a pair.
Here is get_ranks_counts, which takes a list of cards and returns a tuple of ranks and counts. The output is sorted by the rank counts and rank_values for tie breakers. Rank counts are useful for finding what kind of poker ranking hand it is, and ranks are used for the main hand components and kickers.
Call self as a function.
Return ((ranks…), (counts…)) sorted by count then by rank value from cards
([(3, 2, 10, 8), (3, 2, 1, 1)], [♠2, ♥2, ♠3, ♦3, ♣3, ♥8, ♦10])
def test_get_ranks_counts():
test_eq(get_ranks_counts(cards_2kind), ((2, 13, 11, 9, 7, 5), (2, 1, 1, 1, 1, 1)))
test_eq(get_ranks_counts(cards_3kind), ((2, 11, 9, 7, 5), (3, 1, 1, 1, 1)))
test_eq(get_ranks_counts(cards_4kind), ((2, 12, 9, 6), (4, 1, 1, 1)))
test_eq(get_ranks_counts(cards_2pair), ((3, 2, 10, 8, 6), (2, 2, 1, 1, 1)))
test_eq(get_ranks_counts(cards_fullhouse), ((3, 2, 10, 8), (3, 2, 1, 1)))
test_eq(get_ranks_counts(cards_highcard), ((14, 12, 10, 8, 6, 4, 2), (1, 1, 1, 1, 1, 1, 1)))
test_eq(get_ranks_counts(cards_straight), ((12, 10, 7, 6, 5, 4, 3), (1, 1, 1, 1, 1, 1, 1)))
# Edge case: two three-of-a-kinds (e.g. 222 333 K) — full house should pick higher triple
cards_2trips = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[2], ranks[0]),
Card(suits[0], ranks[1]), Card(suits[1], ranks[1]), Card(suits[2], ranks[1]),
Card(suits[3], ranks[11])]
test_eq(get_ranks_counts(cards_2trips), ((3, 2, 13), (3, 3, 1)))
test_get_ranks_counts()As long as the suit count is 5 or more, it is flush because we have seven cards total.
If cards have flush, return the rank_values from the cards in sorted order. If not, return []
def test_is_flush():
# Flush: 5 spades, two side cards
test_eq([str(c) for c in is_flush(cards_flush)], ['♠10', '♠8', '♠6', '♠4', '♠2'])
# Straight flush
test_eq([str(c) for c in is_flush(cards_straightflush)], ['♠7', '♠6', '♠5', '♠4', '♠3'])
# Royal flush
test_eq([str(c) for c in is_flush(cards_royalflush)], ['♠A', '♠K', '♠Q', '♠J', '♠10'])
# No flush: suits too spread out
test_eq(is_flush(cards_fullhouse), [])
test_eq(is_flush(cards_highcard), [])
test_eq(is_flush(cards_4kind), [])
# Edge: all 7 cards same suit — returns all 7
all_spades = [Card('♠', r) for r in 'A,K,Q,J,10,9,8'.split(',')]
test_eq([str(c) for c in is_flush(all_spades)], ['♠A', '♠K', '♠Q', '♠J', '♠10', '♠9', '♠8'])
five_hearts = [Card('♥', r) for r in '2,3,4,5,6'.split(',')] + [Card('♠', 'A'), Card('♦', 'K')]
test_eq([str(c) for c in is_flush(five_hearts)], ['♥6', '♥5', '♥4', '♥3', '♥2'])
test_is_flush()Finding straight is trickier because Ace can serve as 1 or 14. Also, there are seven cards, but only five are used.
Return the highest value of the straight if cards contain 5 consecutive ranks, else 0
def test_is_straight():
# Regular straight: 3-4-5-6-7
test_eq(is_straight(cards_straight), 7)
# Ace-high straight (10-J-Q-K-A)
ace_high = [Card('♠','10'), Card('♥','J'), Card('♦','Q'), Card('♣','K'), Card('♠','A'), Card('♥','3'), Card('♦','6')]
test_eq(is_straight(ace_high), 14)
# Wheel: A-2-3-4-5 (ace low)
wheel = [Card('♠','A'), Card('♥','2'), Card('♦','3'), Card('♣','4'), Card('♠','5'), Card('♥','9'), Card('♦','J')]
test_eq(is_straight(wheel), 5)
# No straight — gapped
test_eq(is_straight(cards_2pair), 0)
# No straight — bunch of duplicates that run together in rank
dupes = [Card('♠','5'), Card('♥','5'), Card('♦','6'), Card('♣','6'), Card('♠','7'), Card('♥','7'), Card('♦','8')]
test_eq(is_straight(dupes), 0)
test_is_straight()Now that we can detect flush and straight, we can update evaluate_hand.
Return hand rank tuple for the best 5-card hand from cards. Hands ranked from 1 (high card) to 10 (royal flush). Each rank tuple starts with the hand category, followed by tiebreaker values (kickers or relevant ranks).
def test_evaluate_hand():
# Royal flush: A♠ K♠ Q♠ J♠ 10♠ → (10,)
test_eq(evaluate_hand(cards_royalflush), (10,))
# Straight flush: 3♠ 4♠ 5♠ 6♠ 7♠ → (9, 7)
test_eq(evaluate_hand(cards_straightflush), (9, 7))
# Four of a kind: 2222 + Q 9 6 → (8, 2, 12)
test_eq(evaluate_hand(cards_4kind), (8, 2, 12))
# Full house: 333 22 + 10 8 → (7, 3, 2)
test_eq(evaluate_hand(cards_fullhouse), (7, 3, 2))
# Flush: 5 spades → (6, 10, 8, 6, 4, 2)
test_eq(evaluate_hand(cards_flush), (6, 10, 8, 6, 4, 2))
# Straight: 3-4-5-6-7 → (5, 7)
test_eq(evaluate_hand(cards_straight), (5, 7))
# Three of a kind: 222 + J 9 7 5 → (4, 2, 11, 9)
test_eq(evaluate_hand(cards_3kind), (4, 2, 11, 9))
# Two pair: 33 22 + 10 8 6 → (3, 3, 2, 10)
test_eq(evaluate_hand(cards_2pair), (3, 3, 2, 10))
# One pair: 22 + K J 9 7 5 → (2, 2, 13, 11, 9)
test_eq(evaluate_hand(cards_2kind), (2, 2, 13, 11, 9))
# High card: A Q 10 8 6 4 2 → (1, 14, 12, 10, 8, 6, 4, 2)
test_eq(evaluate_hand(cards_highcard), (1, 14, 12, 10, 8, 6, 4, 2))
# ace-high straight without flush should NOT be royal flush
cards_ace_straight_noflush = [Card('♠','10'), Card('♥','J'), Card('♦','Q'), Card('♣','K'), Card('♠','A'), Card('♥','3'), Card('♦','6')]
test_eq(evaluate_hand(cards_ace_straight_noflush), (5, 14))
# straight + flush from different cards should NOT be straight flush
cards_straight_and_flush = [Card('♠','2'), Card('♠','4'), Card('♠','6'), Card('♠','8'), Card('♠','10'), Card('♥','5'), Card('♥','7')]
test_eq(evaluate_hand(cards_straight_and_flush), (6, 10, 8, 6, 4, 2))
# ace-high straight + flush, but flush cards not a straight → flush, not royal flush
cards_ace_straight_nonstraight_flush = [Card('♠','A'), Card('♠','K'), Card('♠','Q'), Card('♠','J'), Card('♠','5'), Card('♥','10'), Card('♥','3')]
test_eq(evaluate_hand(cards_ace_straight_nonstraight_flush), (6, 14, 13, 12, 11, 5))
# straight flush hidden when 6+ cards share a suit and top 5 don't form a straight
cards_sf_hidden = [Card('♠','2'), Card('♠','3'), Card('♠','4'), Card('♠','5'), Card('♠','6'), Card('♠','8'), Card('♠','K')]
test_eq(evaluate_hand(cards_sf_hidden), (9, 6))
test_evaluate_hand()compare_hands(hands: List[List[Card]]) -> tuple — Return the winning hand rank among hands
Return the winning hand rank among hands.
def test_compare_hands():
# Clear winner: flush beats one pair
test_eq(compare_hands([cards_2kind, cards_flush]), (6, 10, 8, 6, 4, 2))
# Tie: two identical hands → both should win
test_eq(compare_hands([cards_flush, cards_flush]), (6, 10, 8, 6, 4, 2))
# Three hands, two tie for best
test_eq(compare_hands([cards_2kind, cards_flush, cards_royalflush]), (10,))
# Reverse order shouldn't matter
test_eq(compare_hands([cards_flush, cards_2kind]), (6, 10, 8, 6, 4, 2))
test_compare_hands()Readable name for an evaluate_hand tuple, e.g. (7,3,2) -> ‘full house, threes over twos’.
def test_hand_name():
# Royal flush — only category with no rank mentioned
test_eq(hand_name((10,)), 'royal flush')
# Straight flush / straight — "X-high"
test_eq(hand_name((9, 7)), 'straight flush, seven-high')
test_eq(hand_name((5, 7)), 'straight, seven-high')
test_eq(hand_name((5, 14)), 'straight, ace-high') # ace-high straight
test_eq(hand_name((5, 5)), 'straight, five-high') # wheel (A-2-3-4-5)
# Four of a kind / three of a kind / one pair — "Xs"
test_eq(hand_name((8, 2, 12)), 'four of a kind, twos')
test_eq(hand_name((8, 14, 2)), 'four of a kind, aces')
test_eq(hand_name((4, 2, 11, 9)), 'three of a kind, twos')
test_eq(hand_name((2, 2, 13, 11, 9)), 'one pair, twos')
test_eq(hand_name((2, 14, 13, 11, 9)), 'one pair, aces')
# Full house — "Xs over Ys"
test_eq(hand_name((7, 3, 2)), 'full house, threes over twos')
test_eq(hand_name((7, 14, 13)), 'full house, aces over kings')
# Two pair — "Xs and Ys"
test_eq(hand_name((3, 3, 2, 10)), 'two pair, threes and twos')
test_eq(hand_name((3, 14, 13, 12)), 'two pair, aces and kings')
# Flush / high card — "X-high"
test_eq(hand_name((6, 10, 8, 6, 4, 2)), 'flush, ten-high')
test_eq(hand_name((6, 14, 13, 12, 11, 5)), 'flush, ace-high')
test_eq(hand_name((1, 14, 12, 10, 8, 6, 4, 2)), 'high card, ace-high')
test_eq(hand_name((1, 2, 4, 6, 8, 10, 12)), 'high card, two-high')
test_hand_name()In my original plan, I thought of writing a CardCollection class as an abstract class and create Hand, Deck, and CommunityCards by inheriting from it as they all consist of cards. However, they don’t really have methods that share. To keep things simple, I decided to start implementing Deck as a list of Cards and see how it feels.
A deck consists of 52 cards. From that, we need to shuffle and withdraw cards so the dealer can deal cards.
Create a deck with given suits and ranks
Shuffle deck in place and return it
def test_deck():
# Standard deck: 52 cards, 4 suits × 13 ranks
d = mk_deck()
test_eq(len(d), 52)
# No duplicates
test_eq(len(set(d)), 52)
# Custom: single suit, 5 ranks → 5 cards
d5 = mk_deck(suits[0], ranks[:5])
test_eq(len(d5), 5)
test_eq(d5, [Card('♠','2'), Card('♠','3'), Card('♠','4'), Card('♠','5'), Card('♠','6')])
# Two suits, three ranks → 6 cards
d6 = mk_deck(suits[:2], ranks[:3])
test_eq(len(d6), 6)
# First card is first suit + first rank
test_eq(d6[0], Card('♠','2'))
test_deck()Withdraw n cards from deck and return them. Modifies deck in place.
def test_withdraw_card():
# One card: returns it, deck shrinks by one, card was last in deck
deck = mk_deck()
last_card = deck[-1]
got = withdraw_card(deck)
test_eq(got, [last_card])
test_eq(len(deck), 51)
# n cards: returned in reverse deck order (pop from end)
deck = mk_deck()
expected = list(deck[-3:])[::-1]
got = withdraw_card(deck, 3)
test_eq(got, expected)
test_eq(len(deck), 49)
# Withdrawn cards are exactly the ones removed — nothing lost or duplicated
deck = mk_deck()
full = set(deck)
got = withdraw_card(deck, 5)
test_eq(len(set(got) & set(deck)), 0)
test_eq(set(got) | set(deck), full)
# n=0: no cards, deck unchanged
deck = mk_deck()
test_eq(withdraw_card(deck, 0), [])
test_eq(len(deck), 52)
# Withdraw the whole deck: works, deck now empty
deck = mk_deck()
test_eq(len(withdraw_card(deck, 52)), 52)
test_eq(len(deck), 0)
# Too many: raises, and the deck is untouched
deck = mk_deck()
test_fail(lambda: withdraw_card(deck, 53), contains='Not enough cards')
test_eq(len(deck), 52)
# Depleted deck: raises even for n=1
deck = mk_deck()
withdraw_card(deck, 52)
test_fail(lambda: withdraw_card(deck), contains='Not enough cards')
test_withdraw_card()Monte Carlo estimate of win/tie/lose fractions for hand given community vs n_opponents. Pass seed for reproducible classroom demos.
The equity function provides a great way to simulate what would happen if the game goes on. It counts wins, ties, and loses from 10_000 simulations by default. It’s fun to play with. If I start with two aces, what is a chance of winning against one opponent?
It is 85%, but think about what would happen with 2 opponents?
{'win': 0.7305, 'tie': 0.0042, 'lose': 0.2653}
It dropped to 73%. Why would that be?
What would happen if we have three kings in the community card? How would that change the probability of winning if it changes at all?
Playerclass:
Create a collection of name/value pairs.
Example enumeration:
class Color(Enum): … RED = 1 … BLUE = 2 … GREEN = 3
Access them by:
attribute access:
Color.RED <Color.RED: 1>
value lookup:
Color(1) <Color.RED: 1>
name lookup:
Color[‘RED’] <Color.RED: 1>
Enumerations can be iterated over, and know how many members they have:
len(Color) 3
list(Color) [<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.
Player has a strategy attribute, which chooses actions.
Call self as a function.
Call self as a function.
Call self as a function.
Call self as a function.
Call self as a function.
Initialize self. See help(type(self)) for accurate signature.
galopy intends to raise, stage_bet: 2, raise_amount: 1
(3,
Player(username='galopy', balance=17, bet=3, playing=True, hand=[], strategy=always_raise(1)))
def test_player():
# call: normal case
p = Player('t', balance=100, bet=10)
test_eq(p.call(20), 20)
test_eq(p.balance, 80)
test_eq(p.bet, 30)
# call: need = 0 (already matched)
p = Player('t', balance=100, bet=10)
test_eq(p.call(0), 0)
test_eq(p.balance, 100)
test_eq(p.bet, 10)
# check: no state change
p = Player('t', balance=100, bet=10)
test_eq(p.check(), 0)
test_eq(p.balance, 100)
test_eq(p.bet, 10)
# fold: playing → False, no money changes
p = Player('t', balance=100, bet=10)
test_eq(p.fold(), 0)
test_eq(p.playing, False)
test_eq(p.balance, 100)
# raise_: normal case
p = Player('t', balance=100, bet=10)
test_eq(p.raise_(50), 50)
test_eq(p.balance, 50)
test_eq(p.bet, 60)
# call + raise_ share _place_bet: bets accumulate
p = Player('t', balance=100, bet=10)
p.call(5); p.raise_(5)
test_eq(p.balance, 90)
test_eq(p.bet, 20)
# reset_bet: only bet zeroed, balance untouched
p = Player('t', balance=100, bet=10)
p.reset_bet()
test_eq(p.bet, 0)
test_eq(p.balance, 100)
test_player()def test_action_handler():
# call: pays the difference between stage_bet and current bet
p = Player('t', balance=20, bet=0, strategy=always_call)
test_eq(p.perform_action(2), 2)
test_eq(p.balance, 18)
test_eq(p.bet, 2)
test_eq(p.playing, True)
# call: partial — player already bet 1, only needs 1 more
p = Player('t', balance=20, bet=1, strategy=always_call)
test_eq(p.perform_action(2), 1)
test_eq(p.balance, 19)
test_eq(p.bet, 2)
# check: no state change, returns 0
p = Player('t', balance=20, bet=0, strategy=always_check)
test_eq(p.perform_action(2), 0)
test_eq(p.balance, 20)
test_eq(p.bet, 0)
test_eq(p.playing, True)
# fold: playing → False, no money moves
p = Player('t', balance=20, bet=0, strategy=always_fold)
test_eq(p.perform_action(2), 0)
test_eq(p.playing, False)
test_eq(p.balance, 20)
test_eq(p.bet, 0)
# raise: pays new_stage_bet - current bet
p = Player('t', balance=20, bet=0, strategy=always_raise(5))
test_eq(p.perform_action(2), 7) # 2 + 5 = 7
test_eq(p.balance, 13)
test_eq(p.bet, 7)
# AI folds when it can't afford the call
p = Player('t', balance=1, bet=0, strategy=always_call)
test_eq(p.perform_action(2), 0)
test_eq(p.playing, False)
test_eq(p.balance, 1)
# AI folds when it can't afford the raise
p = Player('t', balance=5, bet=0, strategy=always_raise(10))
test_eq(p.perform_action(2), 0)
test_eq(p.playing, False)
test_eq(p.balance, 5)
test_action_handler()t intends to call, stage_bet: 2, raise_amount: 0
t intends to call, stage_bet: 2, raise_amount: 0
t intends to check, stage_bet: 2, raise_amount: 0
t intends to fold, stage_bet: 2, raise_amount: 0
t intends to raise, stage_bet: 2, raise_amount: 5
t intends to call, stage_bet: 2, raise_amount: 0
t intends to raise, stage_bet: 2, raise_amount: 10
Round Management
Attributes: - stage (pre-flop, flop, turn, river) - players - deck - community_cards
Methods:
betting_round(round) -> None: Manage one round of player actions (pre-flop, flop, turn, or river). Ends when all active players have called or checked.distribute(pot, winners) -> None: Distribute the pot to the winner(s).start_round(round) -> None: Orchestrate the whole hand: deal hole cards → blinds → betting round → flop → betting round → turn → betting round → river → betting round → showdown → distribute.Create a collection of name/value pairs.
Example enumeration:
class Color(Enum): … RED = 1 … BLUE = 2 … GREEN = 3
Access them by:
attribute access:
Color.RED <Color.RED: 1>
value lookup:
Color(1) <Color.RED: 1>
name lookup:
Color[‘RED’] <Color.RED: 1>
Enumerations can be iterated over, and know how many members they have:
len(Color) 3
list(Color) [<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]
Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.
Let’s go through playing a round of poker game!
Initialize self. See help(type(self)) for accurate signature.
def test_distribute():
# Single winner takes the whole pot
r = Round(); r.pot = 10
p = Player(balance=5)
r._distribute([p])
test_eq(p.balance, 15)
# Even split: two winners
r = Round(); r.pot = 10
p1, p2 = Player(balance=5), Player(balance=5)
r._distribute([p1, p2])
test_eq(p1.balance, 10)
test_eq(p2.balance, 10)
# Odd chips: remainder dropped (documents current behavior)
r = Round(); r.pot = 5
p1, p2 = Player(balance=0), Player(balance=0)
r._distribute([p1, p2])
test_eq(p1.balance, 2)
test_eq(p2.balance, 2)
test_distribute()Distributing $ 10 to [Player(username='new_user', balance=5, bet=0, playing=True, hand=[], strategy=always_call)]
Distributing $ 10 to [Player(username='new_user', balance=5, bet=0, playing=True, hand=[], strategy=always_call), Player(username='new_user', balance=5, bet=0, playing=True, hand=[], strategy=always_call)]
Distributing $ 5 to [Player(username='new_user', balance=0, bet=0, playing=True, hand=[], strategy=always_call), Player(username='new_user', balance=0, bet=0, playing=True, hand=[], strategy=always_call)]
def test_prep_round():
# Fresh round: each player gets 2 cards, deck loses 2 per player
players = [Player(balance=20) for _ in range(3)]
r = Round(players=players)
r._prep_round()
test_eq([len(p.hand) for p in players], [2, 2, 2])
test_eq(len(r.deck), 52 - 6)
test_eq(r.stage, Stage.PREFLOP)
test_eq(r.community, [])
test_eq(r.pot, 0)
# Called again: state resets, fresh hands dealt
hands_before = [p.hand for p in players]
r._prep_round()
test_eq([len(p.hand) for p in players], [2, 2, 2])
test_eq(len(r.deck), 52 - 6)
test_ne([p.hand for p in players], hands_before)
test_prep_round()def test_reset_betting_round():
# Dirty state gets fully reset
players = [Player(balance=20, bet=3) for _ in range(2)]
r = Round(players=players)
r._idx, r.stage_bet, r._raiser = 1, 10, 1
r._reset_betting_round()
test_eq([p.bet for p in players], [0, 0])
test_eq(r.stage_bet, 0)
test_eq(r._idx, 0)
test_eq(r._raiser, -1)
# Balances untouched — only bets reset
test_eq([p.balance for p in players], [20, 20])
test_reset_betting_round()def test_deal_community():
# PREFLOP
r = Round()
r._deal_community()
test_eq(len(r.community), 0)
test_eq(len(r.deck), 52)
# FLOP: 3 cards dealt, appended to community
r.stage = Stage.FLOP
r._deal_community()
test_eq(len(r.community), 3)
test_eq(len(r.deck), 52 - 3)
# TURN: 1 more card appended
r.stage = Stage.TURN
r._deal_community()
test_eq(len(r.community), 4)
test_eq(len(r.deck), 52 - 4)
# RIVER: same logic as TURN — 1 card
r.stage = Stage.RIVER
r._deal_community()
test_eq(len(r.community), 5)
test_eq(len(r.deck), 52 - 5)
test_deal_community()
--- PREFLOP --- Community: []
--- FLOP --- Community: [♣7, ♠J, ♦10]
--- TURN --- Community: [♣7, ♠J, ♦10, ♠4]
--- RIVER --- Community: [♣7, ♠J, ♦10, ♠4, ♥Q]
def test_showdown():
# Clear winner: player1 flush; player2 high card; player3 folded with
# a winning hand, excluded from evaluation
community = [Card('♠','6'), Card('♠','8'), Card('♠','10'), Card('♥','5'), Card('♦','9')]
p1 = Player(username='a', hand=[Card('♠','2'), Card('♠','4')])
p2 = Player(username='b', hand=[Card('♥','3'), Card('♦','7')])
p3 = Player(username='c', hand=[Card('♠','A'), Card('♠','K')], playing=False)
r = Round(players=[p1,p2,p3], community=community, pot=10)
r._showdown()
test_eq(p1.balance, 10)
test_eq(p2.balance, 0)
test_eq(p3.balance, 0) # folded: excluded even though hand would win
# Tie: community royal flush — both players split the pot
community = [Card('♠','10'), Card('♠','J'), Card('♠','Q'), Card('♠','K'), Card('♠','A')]
p1 = Player(username='a', hand=[Card('♥','2'), Card('♦','3')])
p2 = Player(username='b', hand=[Card('♣','4'), Card('♠','5')])
r = Round(players=[p1,p2], community=community, pot=10)
r._showdown()
test_eq(p1.balance, 5)
test_eq(p2.balance, 5)
test_showdown()Winners: ['a'] with (6, 10, 8, 6, 4, 2)
Distributing $ 10 to [Player(username='a', balance=0, bet=0, playing=True, hand=[♠2, ♠4], strategy=always_call)]
Winners: ['a', 'b'] with (10,)
Distributing $ 10 to [Player(username='a', balance=0, bet=0, playing=True, hand=[♥2, ♦3], strategy=always_call), Player(username='b', balance=0, bet=0, playing=True, hand=[♣4, ♠5], strategy=always_call)]
Deal, start PREFLOP, and auto-advance to first human player.
def test_start_round():
# A raises 1 each betting round, B calls, C always folds
a = Player('a', balance=20, strategy=always_raise(1))
b = Player('b', balance=20, strategy=always_call)
c = Player('c', balance=20, strategy=always_fold)
r = Round(players=[a, b, c])
list(r.start_round()) # consume the generator; all AI, so no yields
# 4 betting rounds × (A:1 + B:1) = 8 chips, all paid out
test_eq(sum(p.balance for p in r.players), 60) # no chips created or lost
test_eq(a.balance + b.balance, 40) # 8 chips moved A+B → winner
test_eq(c.balance, 20) # folded, never paid
test_start_round()
--- PREFLOP --- Community: []
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0
c intends to fold, stage_bet: 1, raise_amount: 0
--- FLOP --- Community: [♥3, ♥K, ♣9]
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0
--- TURN --- Community: [♥3, ♥K, ♣9, ♥2]
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0
--- RIVER --- Community: [♥3, ♥K, ♣9, ♥2, ♠6]
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0
Winners: ['a'] with (6, 13, 10, 9, 3, 2)
Distributing $ 8 to [Player(username='a', balance=16, bet=1, playing=True, hand=[♥10, ♥9], strategy=always_raise(1))]
Call self as a function.
player1 = Player('galopy', balance=20, playing=True, strategy=always_fold)
player2 = Player('jalopy', balance=20, playing=False)
player3 = Player('zalopy', balance=20, playing=True, strategy=always_call)
players = [player1, player2, player3]
round1 = Round(players = players)
game = round1.start_round()
player = next(game, None)
round1.state()
--- PREFLOP --- Community: []
galopy intends to fold, stage_bet: 0, raise_amount: 0
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0
--- FLOP --- Community: [♠9, ♣A, ♥8]
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0
--- TURN --- Community: [♠9, ♣A, ♥8, ♠8]
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0
--- RIVER --- Community: [♠9, ♣A, ♥8, ♠8, ♦K]
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0
Winners: ['zalopy'] with (4, 8, 14, 13)
Distributing $ 0 to [Player(username='zalopy', balance=20, bet=0, playing=True, hand=[♣J, ♣8], strategy=always_call)]
'Game is over'
We can create a Game class that simplifies playing the game.
Initialize self. See help(type(self)) for accurate signature.
--- PREFLOP --- Community: []
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0
--- FLOP --- Community: [♦6, ♦J, ♦9]
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0
--- TURN --- Community: [♦6, ♦J, ♦9, ♥J]
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0
--- RIVER --- Community: [♦6, ♦J, ♦9, ♥J, ♦3]
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0
Winners: ['bot'] with (6, 13, 11, 9, 6, 3)
Distributing $ 8 to [Player(username='bot', balance=16, bet=1, playing=True, hand=[♦K, ♥Q], strategy=always_raise(1))]
Game is over