Source code for blackjack21.table

__all__ = (
    "DEFAULT_SUITS",
    "Action",
    "CardSource",
    "Table",
    "shoe_reset_hook",
    "validate_player",
)

from collections.abc import Callable, Iterable, Iterator, Sequence
from enum import Enum
from typing import Final, Protocol

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

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


[docs] class Action(str, Enum): """Player actions during their turn.""" HIT = "hit" STAND = "stand" SPLIT = "split" DOUBLE = "double" SURRENDER = "surrender"
[docs] class CardSource(Protocol): """Anything that can provide cards."""
[docs] def draw_card(self) -> Card: ...
def __len__(self) -> int: ...
[docs] def validate_player(name: str, bet: int) -> tuple[PlayerName, BetAmount]: """Validate raw player data at the boundary and wrap in domain types.""" if not name: raise InvalidPlayersData((name, bet)) if bet <= 0: raise InvalidPlayersData((name, bet)) return PlayerName(name), BetAmount(bet)
[docs] def shoe_reset_hook(deck: Deck, threshold: float = 0.75) -> Callable[[], None]: """Standard shoe management: reset when penetration exceeds threshold.""" def _check_and_reset() -> None: if deck.penetration > threshold: deck.reset() return _check_and_reset
def _check_surrender(hand: Hand, *, has_split: bool) -> str | None: """Returns None if legal, or a reason string if not.""" if hand.is_complete: return "hand is already complete" if len(hand) != 2: return "not on first turn" if has_split: return "not allowed after split" return None def _check_double(hand: Hand) -> str | None: """Returns None if legal, or a reason string if not.""" if hand.is_complete: return "hand is already complete" if len(hand) != 2: return "not on first turn" return None def _check_split(hand: Hand) -> str | None: """Returns None if legal, or a reason string if not.""" if hand.is_complete: return "hand is already complete" if len(hand) != 2: return "not on first turn" if hand[0].value != hand[1].value: return "cards do not have equal value" return None
[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 card source 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. :param on_round_reset: Optional callback invoked when a round resets. """ __slots__ = ( "_current_hand_idx", "_current_player_idx", "_dealer", "_deck", "_initial_players_data", "_on_round_reset", "_players", "_state", ) def __init__( self, players: Iterable[tuple[str, int]], deck: CardSource, *, dealer_name: str = "Dealer", hit_soft_17: bool = False, on_round_reset: Callable[[], None] | None = None, ) -> None: self._deck = deck self._dealer = Dealer(dealer_name, hit_soft_17=hit_soft_17) self._on_round_reset = on_round_reset self._initial_players_data: list[tuple[PlayerName, BetAmount]] = [] self._players: list[Player] = [] for raw_name, raw_bet in players: name, bet = validate_player(raw_name, raw_bet) self._initial_players_data.append((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 __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) -> CardSource: """The table's card source.""" return self._deck @property def dealer(self) -> Dealer: """Table's Dealer class object.""" return self._dealer @property def players(self) -> list[Player]: """List of Player class objects for the Table.""" return self._players @property def state(self) -> GameState: """The current phase of the game.""" return self._state @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 @property def current_player(self) -> Player | None: """The player whose hand is currently being played.""" if self._current_player_idx is None: return None return self._players[self._current_player_idx]
[docs] def available_actions(self) -> frozenset[Action]: """Returns the set of legal actions for the current hand.""" hand = self.current_hand if not hand or hand.is_complete: return frozenset() player = self.current_player has_split = len(player.hands) > 1 actions: set[Action] = {Action.HIT, Action.STAND} if _check_double(hand) is None: actions.add(Action.DOUBLE) if _check_surrender(hand, has_split=has_split) is None: actions.add(Action.SURRENDER) if _check_split(hand) is None: actions.add(Action.SPLIT) return frozenset(actions)
def _next_hand(self) -> None: """Advances to the next available (non-complete) player hand.""" if self._current_player_idx is None or self._current_hand_idx is None: return self._current_hand_idx += 1 while self._current_player_idx < len(self._players): player = self._players[self._current_player_idx] while self._current_hand_idx < len(player.hands): if not player.hands[self._current_hand_idx].is_complete: return self._current_hand_idx += 1 self._current_player_idx += 1 self._current_hand_idx = 0 self._current_player_idx = None self._current_hand_idx = None self._state = GameState.DEALER_TURN self._play_dealer_and_end_game() def _play_dealer_and_end_game(self) -> None: """Plays the dealer's hand and calculates results.""" for player in self._players: for hand in player.hands: if not hand.is_complete: raise PlayDealerFailure(player.name) while not self._dealer.stand: self._dealer.add_card(self._deck.draw_card()) 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: 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 is_blackjack = ( len(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.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)) if self._on_round_reset: self._on_round_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: raise InvalidActionError( "Cannot start game: not enough cards in the deck to deal.", ) # 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 self._calculate_results() self._state = GameState.ROUND_OVER else: self._state = GameState.PLAYERS_TURN if self.current_hand and self.current_hand.is_complete: 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) player = self.current_player assert player is not None # Guaranteed when current_hand is truthy if hand.is_complete: raise PlayFailure(player.name, "hit (hand is already complete)") card = self._deck.draw_card() hand.add_card(card) if hand.is_complete: 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.mark_stood() 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) player = self.current_player assert player is not None # Guaranteed when current_hand is truthy reason = _check_surrender(hand, has_split=len(player.hands) > 1) if reason: raise PlayFailure(player.name, f"surrender ({reason})") hand.surrender() 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) player = self.current_player assert player is not None # Guaranteed when current_hand is truthy reason = _check_double(hand) if reason: raise PlayFailure(player.name, f"double down ({reason})") # Draw card first to ensure the deck has cards before modifying state. card = self._deck.draw_card() hand.double_bet() hand.add_card(card) # A doubled hand must stand. hand.mark_stood() 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) player = self.current_player assert player is not None # Guaranteed when current_hand is truthy reason = _check_split(hand) if reason: raise PlayFailure(player.name, f"split ({reason})") # Draw cards first to ensure deck has enough cards before splitting. card1 = self._deck.draw_card() card2 = self._deck.draw_card() split_rank = hand[0].rank split_card = hand.pop_card() new_hand = Hand(hand.bet) new_hand.add_card(split_card) # Insert the new hand immediately after the current one. player.insert_hand_after(hand, new_hand) # Deal new cards to each hand. hand.add_card(card1) new_hand.add_card(card2) # If Aces were split, both hands stand automatically. if split_rank == "A": hand.mark_stood() new_hand.mark_stood() self._next_hand() elif hand.is_complete: # e.g. got 21 on the first hand after split self._next_hand()