Source code for blackjack21.table

from __future__ import annotations

__all__ = ("Players", "Table")
from typing import TYPE_CHECKING, Final

from .dealer import Dealer
from .exceptions import (
    EmptyDeckError,
    InvalidActionError,
    InvalidPlayersData,
    PlayDealerFailure,
    PlayFailure,
)
from .players import BetAmount, GameResult, GameState, Hand, Player, PlayerName

if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator, Sequence

    from .deck import Card, CardSuit, Deck

Players = list[Player]

DEFAULT_SUITS: Final[Sequence[CardSuit]] = ("Hearts", "Diamonds", "Spades", "Clubs")


[docs] class Table: """Create object for this class to initialize a blackjack table (Iterable through players). :param players: An iterable of player tuples `(name, bet)`. :param deck: A `Deck` object to be used for the game. :param dealer_name: The name of the dealer. :param hit_soft_17: bool, whether the dealer hits on a soft 17. """ __slots__ = ( "_current_hand_idx", "_current_player_idx", "_dealer", "_deck", "_initial_players_data", "_players", "_state", ) def __init__( self, players: Iterable[tuple[PlayerName, BetAmount]], deck: Deck, *, dealer_name: str = "Dealer", hit_soft_17: bool = False, ) -> None: self._deck = deck self._dealer = Dealer(dealer_name, hit_soft_17=hit_soft_17) self._initial_players_data = list(players) self._players = [] for name, bet in self._initial_players_data: if not name or bet <= 0: raise InvalidPlayersData((name, bet)) self._players.append(Player(name, bet)) self._current_player_idx: int | None = None self._current_hand_idx: int | None = None self._state = GameState.INIT def __repr__(self) -> str: return f"<Table dealer: {self._dealer} players: {len(self._players)}>" def __str__(self) -> str: return f"<Table dealer: {self._dealer} players: {len(self._players)}>" def __iter__(self) -> Iterator[Player]: """Iterate through the players on the table.""" yield from self._players def __getitem__(self, index: int) -> Player: """Player at index.""" return self._players[index] def __len__(self) -> int: """Number of players on the table.""" return len(self._players) @property def deck(self) -> Deck: """Table's Deck class object.""" return self._deck @property def dealer(self) -> Dealer: """Table's Dealer class object.""" return self._dealer @property def players(self) -> Players: """List of Player class objects for the Table.""" return self._players @property def dealer_visible_hand(self) -> list[Card]: """The dealer's hand that is visible to players. If the players' turns are over, it returns the full hand. Otherwise, it returns only the first card (the up-card). """ if self._state in (GameState.DEALER_TURN, GameState.ROUND_OVER): return self._dealer.hand return self._dealer.hand[:1] @property def current_hand(self) -> Hand | None: """The hand that is currently being played.""" if self._state != GameState.PLAYERS_TURN: return None if self._current_player_idx is None or self._current_hand_idx is None: return None try: player = self._players[self._current_player_idx] return player.hands[self._current_hand_idx] except IndexError: # This can happen if a player list or hand list is empty return None def _next_hand(self) -> None: """Advances to the next available (non-standing) player hand.""" if self._current_player_idx is None or self._current_hand_idx is None: return # Game over or not started # Start by checking the next hand for the current player self._current_hand_idx += 1 while self._current_player_idx < len(self._players): player = self._players[self._current_player_idx] # Check remaining hands for the current player while self._current_hand_idx < len(player.hands): if not player.hands[self._current_hand_idx].stand: return # Found the next active hand self._current_hand_idx += 1 # No more hands for this player, move to the next player self._current_player_idx += 1 self._current_hand_idx = 0 # Reset hand index for the new player # If we exit the outer loop, there are no more players or hands. self._current_player_idx = None self._current_hand_idx = None self._state = GameState.DEALER_TURN self._play_dealer_and_end_game() def _draw_card(self) -> Card: """Draws a card from the deck, resetting if it's empty.""" try: return self._deck.draw_card() except EmptyDeckError: self._deck.reset() # After reset, if the deck is still empty, it's an unrecoverable state. # This should not happen in a normal game. if not self._deck: msg = "Deck is empty even after reset. Cannot continue." raise RuntimeError(msg) return self._deck.draw_card() def _play_dealer_and_end_game(self) -> None: """Plays the dealer's hand and calculates results for all player hands.""" for player in self._players: for hand in player.hands: if not hand.stand: raise PlayDealerFailure(player.name) self._dealer.play(self._deck) self._calculate_results() self._state = GameState.ROUND_OVER def _calculate_results(self) -> None: """Calculates the game result for each hand against the dealer.""" dealer_total = self._dealer.total dealer_is_bust = self._dealer.bust dealer_has_blackjack = len(self._dealer.hand) == 2 and dealer_total == 21 for player in self._players: # Check if this player has split player_has_split = len(player.hands) > 1 for hand in player.hands: if hand.surrendered: hand.result = GameResult.SURRENDER continue if hand.bust: hand.result = GameResult.PLAYER_BUST continue # Blackjack check is_blackjack = ( len(hand.hand) == 2 and hand.total == 21 and not player_has_split ) if is_blackjack: # Push if dealer also has blackjack, otherwise it's a win. if dealer_has_blackjack: hand.result = GameResult.PUSH else: hand.result = GameResult.BLACKJACK continue # If dealer has a blackjack and the player does not, the dealer wins. # This correctly handles a player's multi-card 21 vs a dealer's natural blackjack. if dealer_has_blackjack: hand.result = GameResult.DEALER_WIN continue if dealer_is_bust: hand.result = GameResult.DEALER_BUST elif hand.total > dealer_total: hand.result = GameResult.PLAYER_WIN elif hand.total < dealer_total: hand.result = GameResult.DEALER_WIN else: # hand.total == dealer_total hand.result = GameResult.PUSH def _reset_round(self) -> None: """Resets the table for a new round of play.""" self._dealer.clear_hand() self._players.clear() for name, bet in self._initial_players_data: # Re-instantiate players for a clean state self._players.append(Player(name, bet)) # Reshuffle the deck if card penetration is over 75% if self._deck.penetration > 0.75: self._deck.reset() self._current_player_idx = None self._current_hand_idx = None
[docs] def start_game(self) -> None: """Deals the initial cards to start the game.""" if not self._players: return if self._state not in (GameState.INIT, GameState.ROUND_OVER): msg = "Cannot start game: a round is already in progress." raise InvalidActionError(msg) if self._state == GameState.ROUND_OVER: self._reset_round() # Check if there are enough cards for the initial deal. cards_needed = (len(self._players) + 1) * 2 if len(self._deck) < cards_needed: self._deck.reset() # Try resetting the deck if len(self._deck) < cards_needed: msg = "Cannot start game: not enough cards in the deck to deal." raise InvalidActionError( msg, ) # Deal first card to each hand, then to dealer for player in self._players: for hand in player.hands: hand.add_card(self._deck.draw_card()) self._dealer.add_card(self._deck.draw_card()) # Dealer's first card # Deal second card to each hand for player in self._players: for hand in player.hands: hand.add_card(self._deck.draw_card()) self._dealer.add_card(self._deck.draw_card()) self._current_player_idx = 0 self._current_hand_idx = 0 # If dealer has 21, game ends immediately. Otherwise, if first player has 21, move to next. if self._dealer.total == 21: self._state = GameState.DEALER_TURN # Players' turn is skipped self._calculate_results() # Go straight to calculating results self._state = GameState.ROUND_OVER else: self._state = GameState.PLAYERS_TURN if self.current_hand and self.current_hand.stand: self._next_hand()
[docs] def hit(self) -> Card: """The current hand takes another card. Advances to the next hand if it busts or stands.""" if self._state != GameState.PLAYERS_TURN: msg = "Cannot hit: it is not the players' turn." raise InvalidActionError(msg) hand = self.current_hand if not hand: msg = "Cannot hit: there is no active hand." raise InvalidActionError(msg) if hand.stand: msg = hand.player.name raise PlayFailure(msg, "hit (hand is already standing)") card = self._draw_card() hand.add_card(card) if hand.stand: # Automatically stands on 21 or bust self._next_hand() return card
[docs] def stand(self) -> None: """The current hand stands. The game moves to the next hand.""" if self._state != GameState.PLAYERS_TURN: msg = "Cannot stand: it is not the players' turn." raise InvalidActionError(msg) if not self.current_hand: msg = "Cannot stand: there is no active hand." raise InvalidActionError(msg) self.current_hand.stand_action() self._next_hand()
[docs] def surrender(self) -> None: """The current hand surrenders. Only allowed on the first two cards.""" if self._state != GameState.PLAYERS_TURN: msg = "Cannot surrender: it is not the players' turn." raise InvalidActionError(msg) hand = self.current_hand if not hand: msg = "Cannot surrender: there is no active hand." raise InvalidActionError(msg) if hand.stand: msg = hand.player.name raise PlayFailure(msg, "surrender (hand is already standing)") if len(hand.hand) != 2: msg = hand.player.name raise PlayFailure(msg, "surrender (not on first turn)") # Check if the player has split player = hand.player if len(player.hands) > 1: msg = player.name raise PlayFailure(msg, "surrender (not allowed after split)") hand._surrendered = True hand.stand_action() self._next_hand()
[docs] def double_down(self) -> Card: """The current hand doubles the bet, takes one more card, and stands.""" if self._state != GameState.PLAYERS_TURN: msg = "Cannot double down: it is not the players' turn." raise InvalidActionError(msg) hand = self.current_hand if not hand: msg = "Cannot double down: there is no active hand." raise InvalidActionError(msg) if hand.stand: msg = hand.player.name raise PlayFailure(msg, "double down (hand is already standing)") if len(hand.hand) != 2: msg = hand.player.name raise PlayFailure(msg, "double down (not on first turn)") # Draw card first to ensure the deck is not empty before changing state. card = self._draw_card() hand._bet *= 2 hand.add_card(card) # A doubled hand must stand. hand.stand_action() self._next_hand() return card
[docs] def split(self) -> None: """Splits the current hand if the two cards have the same value.""" if self._state != GameState.PLAYERS_TURN: msg = "Cannot split: it is not the players' turn." raise InvalidActionError(msg) hand = self.current_hand if not hand: msg = "Cannot split: there is no active hand." raise InvalidActionError(msg) if ( hand.stand or len(hand.hand) != 2 or hand.hand[0].value != hand.hand[1].value ): msg = hand.player.name raise PlayFailure(msg, "split") # Draw cards first to ensure deck has enough cards before splitting. card1 = self._draw_card() card2 = self._draw_card() # Store the rank to check for Aces split_rank = hand.hand[0].rank # Find the player who owns this hand player = hand.player split_card = hand.hand.pop() new_hand = Hand(player, hand.bet) new_hand.add_card(split_card) player.hands.insert(player.hands.index(hand) + 1, new_hand) # Deal new cards hand.add_card(card1) new_hand.add_card(card2) # If the first hand is now 21, it stands automatically. Move to the next hand. # Also, if Aces were split, the player cannot hit, so we stand both hands. if split_rank == "A": hand.stand_action() new_hand.stand_action() self._next_hand() elif hand.stand: # e.g. got 21 on the first hand after split self._next_hand()