From 5e620fb5cac30c19def733132cbfaef164f02fe8 Mon Sep 17 00:00:00 2001 From: aconconi Date: Sun, 8 Mar 2026 17:52:04 +0100 Subject: [PATCH 1/9] 71_Poker finally ported to Python --- 71_Poker/python/README.md | 24 ++ 71_Poker/python/cards/__init__.py | 11 + 71_Poker/python/cards/card.py | 50 +++ 71_Poker/python/cards/deck.py | 17 + 71_Poker/python/game.py | 397 ++++++++++++++++++++++++ 71_Poker/python/players/__init__.py | 7 + 71_Poker/python/players/dealer.py | 187 +++++++++++ 71_Poker/python/players/human.py | 21 ++ 71_Poker/python/players/player.py | 51 +++ 71_Poker/python/poker.py | 38 +++ 71_Poker/python/pokerhand/__init__.py | 7 + 71_Poker/python/pokerhand/evaluation.py | 206 ++++++++++++ 71_Poker/python/pokerhand/hand.py | 39 +++ 71_Poker/python/pokerhand/rank.py | 31 ++ 14 files changed, 1086 insertions(+) create mode 100644 71_Poker/python/cards/__init__.py create mode 100644 71_Poker/python/cards/card.py create mode 100644 71_Poker/python/cards/deck.py create mode 100644 71_Poker/python/game.py create mode 100644 71_Poker/python/players/__init__.py create mode 100644 71_Poker/python/players/dealer.py create mode 100644 71_Poker/python/players/human.py create mode 100644 71_Poker/python/players/player.py create mode 100644 71_Poker/python/poker.py create mode 100644 71_Poker/python/pokerhand/__init__.py create mode 100644 71_Poker/python/pokerhand/evaluation.py create mode 100644 71_Poker/python/pokerhand/hand.py create mode 100644 71_Poker/python/pokerhand/rank.py diff --git a/71_Poker/python/README.md b/71_Poker/python/README.md index 781945ec4..31a4baa79 100644 --- a/71_Poker/python/README.md +++ b/71_Poker/python/README.md @@ -1,3 +1,27 @@ Original source downloaded [from Vintage Basic](http://www.vintage-basic.net/games.html) Conversion to [Python](https://www.python.org/about/) + + +## Known BASIC bugs — fixed + +- **Tie tack unreachable (line 3970):** The condition + `IF O/3<>INT(O/3) THEN 4090` is logically inverted. It checks if the + tie tack has *not* been sold, and if so, incorrectly jumps to the + "Bust" message. This makes the tie tack sale permanently unreachable + even if the player still owns it. The Python port fixes this to restore + the original intended experience where both assets can be sold. + +## Known BASIC bugs/quirks — kept faithful + +- **$50 buy-back materialises from nowhere (lines 3570/3640):** + When the player buys back a pawned item, `C=C+50` adds $50 to the + dealer's stack without deducting from the player. Kept as-is — it + models the dealer/pawnbroker liquidating the physical item. + +- **Yes/No prompts accept anything (e.g. lines 3550, 3880):** + `LEFT$(J$,1)="Y"` treats every non-"Y" input as "no" with no + re-prompt. The Python port normalises all yes/no prompts to a single + `_read_yes_no` that retries on invalid input (matching the stricter + "Do you wish to continue?" prompt at lines 4120–4150). + diff --git a/71_Poker/python/cards/__init__.py b/71_Poker/python/cards/__init__.py new file mode 100644 index 000000000..956089deb --- /dev/null +++ b/71_Poker/python/cards/__init__.py @@ -0,0 +1,11 @@ +"""Cards package initialization.""" + +from .card import Card, CardRank, CardSuit +from .deck import Deck + +__all__ = [ + "Card", + "CardRank", + "CardSuit", + "Deck", +] diff --git a/71_Poker/python/cards/card.py b/71_Poker/python/cards/card.py new file mode 100644 index 000000000..d16747ef1 --- /dev/null +++ b/71_Poker/python/cards/card.py @@ -0,0 +1,50 @@ +"""Card primitives: Suits, Ranks, and the Card class.""" + +from dataclasses import dataclass +from enum import Enum + + +class CardSuit(Enum): + """Enumeration of card suits.""" + + CLUBS = 0 + DIAMONDS = 1 + HEARTS = 2 + SPADES = 3 + + def __str__(self) -> str: + return self.name.capitalize() + + +class CardRank(Enum): + """Card ranks from Two (0) to Ace (12).""" + + TWO = 0 + THREE = 1 + FOUR = 2 + FIVE = 3 + SIX = 4 + SEVEN = 5 + EIGHT = 6 + NINE = 7 + TEN = 8 + JACK = 9 + QUEEN = 10 + KING = 11 + ACE = 12 + + def __str__(self) -> str: + if self.value <= 8: # TWO(0) through TEN(8) + return f" {str(self.value + 2)} " + return ("Jack", "Queen", "King", "Ace")[self.value - 9] + + +@dataclass(frozen=True) +class Card: + """A single playing card.""" + + suit: CardSuit + rank: CardRank + + def __str__(self) -> str: + return f"{self.rank} of {self.suit}" diff --git a/71_Poker/python/cards/deck.py b/71_Poker/python/cards/deck.py new file mode 100644 index 000000000..862d33e42 --- /dev/null +++ b/71_Poker/python/cards/deck.py @@ -0,0 +1,17 @@ +"""Deck implementation.""" + +import random + +from .card import Card, CardRank, CardSuit + + +class Deck: # pylint: disable=too-few-public-methods + """Standard 52-card deck, shuffled on creation.""" + + def __init__(self) -> None: + self._cards = [Card(suit, rank) for suit in CardSuit for rank in CardRank] + random.shuffle(self._cards) + + def deal(self) -> Card: + """Remove and return the top card.""" + return self._cards.pop() diff --git a/71_Poker/python/game.py b/71_Poker/python/game.py new file mode 100644 index 000000000..6e322e9ae --- /dev/null +++ b/71_Poker/python/game.py @@ -0,0 +1,397 @@ +"""Poker game logic — port of the 1978 Creative Computing BASIC original.""" + +import random +from typing import NoReturn + +from cards import Deck +from players import Dealer, Human, Player +from pokerhand import Hand + + +class GameOver(Exception): + """Raised when the game ends (either side busted, player quits, or walks away).""" + + +class PokerGame: # pylint: disable=too-few-public-methods + """Five-card draw poker against the house.""" + + INITIAL_MONEY: int = 200 + ANTE: int = 5 + + human: Human + dealer: Dealer + deck: Deck + pot: int + + def __init__(self) -> None: + """Initialise game state and allocate both sides' starting stack.""" + self.human = Human(PokerGame.INITIAL_MONEY) + self.dealer = Dealer(PokerGame.INITIAL_MONEY) + self.deck: Deck + self.pot = 0 + + # ------------------------------------------------------------------ + # Intro and main loop + # ------------------------------------------------------------------ + + def run(self) -> None: + """Execute the primary game loop until a GameOver exception occurs.""" + self._display_intro() + while True: + self._play_round() + + # ------------------------------------------------------------------ + # Round + # ------------------------------------------------------------------ + + def _play_round(self) -> None: + """Deal one complete round: ante, bet, draw, bet, showdown.""" + print() + + # Reset per-round state + self.deck = Deck() + + # --- Ante --- + if self.dealer.money <= self.ANTE: + self._dealer_goes_bust() + + print(f"The ante is ${self.ANTE}. I will deal:\n") + + if self.human.money <= self.ANTE: + self._human_try_raise_funds(self.ANTE) + + self.pot += self.ANTE * 2 + self.human.pay_ante(self.ANTE) + self.dealer.pay_ante(self.ANTE) + + # --- Deal --- + self.human.receive_new_hand(Hand([self.deck.deal() for _ in range(5)])) + self.dealer.receive_new_hand(Hand([self.deck.deal() for _ in range(5)])) + + print("Your hand:") + print(self.human.hand) + print() + + dealer_action = self.dealer.get_opening_action() + if self.dealer.money < self.dealer.bet: + self._dealer_try_raise_funds() + + if dealer_action == Player.Action.CHECK: + print("I check.") + else: + print(f"I'll open with $ {self.dealer.bet}") + + if not self._conduct_betting_round(post_draw=False): + return + + # --- Draw --- + print() + self._player_discard_draw() + draw_count = self.dealer.discard_and_draw(self.deck) + print( + f"\nI am taking {draw_count} card{'s' if draw_count != 1 else ''}" + ) + + self.dealer.decide_postdraw_bet() + + if not self._conduct_betting_round(post_draw=True): + return + + # --- Showdown --- + self._showdown() + + # ------------------------------------------------------------------ + # Player draw + # ------------------------------------------------------------------ + + def _player_discard_draw(self) -> None: + """Ask the player how many cards to replace, then deal replacements.""" + count = self._read_int( + "Now we draw -- how many cards do you want? ", + low=0, + high=3, + too_high_msg="You can't draw more than three cards.", + ) + + if count == 0: + return + + print("What are their numbers:") + for _ in range(count): + pos = self._read_int("", low=1, high=5) + self.human.replace_card(pos - 1, self.deck.deal()) + + print("Your new hand:") + print(self.human.hand) + + # ------------------------------------------------------------------ + # Betting round + # ------------------------------------------------------------------ + + def _conduct_betting_round(self, post_draw: bool) -> bool: + """Core loop for a betting round. Returns True to continue the round.""" + while True: + human_action = self._get_human_action() + + match human_action: + case Player.Action.FOLD: + self._settle_bets() + self._award_pot(dealer_wins=True) + return False + + case Player.Action.CHECK: + if post_draw: + if self._handle_post_draw_check(): + return True + continue + return True + + case Player.Action.CALL: + self._settle_bets() + return True + + case Player.Action.RAISE: + if self._handle_human_raise(): + return True + + case _: + raise ValueError("Unknown player action.") + + def _handle_post_draw_check(self) -> bool: + """Process dealer response to human check. Returns True if phase ends.""" + dealer_action = self.dealer.get_check_response() + if self.dealer.money < self.dealer.bet: + self._dealer_try_raise_funds() + + if dealer_action == Player.Action.CHECK: + print("I'll check.") + self._settle_bets() + return True + + print(f"I'll bet $ {self.dealer.bet}") + return False + + def _handle_human_raise(self) -> bool: + """Process dealer response to human raise. Returns True if phase ends.""" + dealer_action = self.dealer.get_raise_response(self.human.bet) + if self.dealer.money < self.dealer.bet: + self._dealer_try_raise_funds() + + match dealer_action: + case Player.Action.FOLD: + print("I fold.") + self._settle_bets() + self._award_pot(dealer_wins=False) + return True # Round ends + + case Player.Action.CALL: + print("I'll see you.") + self._settle_bets() + return True + + case Player.Action.RAISE: + raise_amount = self.dealer.bet - self.human.bet + print(f"I'll see you, and raise you {raise_amount}") + return False + + case _: + raise ValueError("Unknown dealer action.") + + def _get_human_action(self) -> Player.Action: + """Prompt user for a bet and return the resulting Action.""" + while True: + print() + raw = input("What is your bet? ").strip() + + try: + bet = float(raw) + except ValueError: + continue + + # Fractional bet: only .5 is valid, and only as a check + if bet != int(bet): + if self.dealer.bet == 0 and self.human.bet == 0 and bet == 0.5: + return Player.Action.CHECK + print("No small change, please.") + continue + + amount = int(bet) + + if amount == 0: + return Player.Action.FOLD + + if self.human.bet + amount > self.human.money: + self._human_try_raise_funds(self.human.bet + amount) + continue + + if self.human.bet + amount < self.dealer.bet: + print("If you can't see my bet, then fold.") + continue + + self.human.bet += amount + + if self.human.bet == self.dealer.bet: + return Player.Action.CALL + return Player.Action.RAISE + + def _dealer_try_raise_funds(self) -> None: + """Attempt to raise funds for the dealer. Raises GameOver if failed.""" + if not self.human.has_watch: + if self._read_yes_no( + "Would you like to buy back your watch for $50? " + ): + self.dealer.money += 50 + self.human.has_watch = True + if self.dealer.money >= self.dealer.bet: + return + + if not self.human.has_tack: + if self._read_yes_no( + "Would you like to buy back your tie tack for $50? " + ): + self.dealer.money += 50 + self.human.has_tack = True + if self.dealer.money >= self.dealer.bet: + return + + self._dealer_goes_bust() + + def _dealer_goes_bust(self) -> NoReturn: + """Inform the player and terminate the game.""" + print("I'm busted. Congratulations!") + raise GameOver + + def _human_try_raise_funds(self, target_money: int) -> None: + """Offer the player's assets for cash. Raises GameOver if short of target.""" + print("\nYou can't bet with what you haven't got.") + + if self.human.has_watch: + if self._read_yes_no("Would you like to sell your watch? "): + if self._chance(3): + print("That's a pretty crummy watch - I'll give you $25.") + self.human.money += 25 + else: + print("I'll give you $75 for it.") + self.human.money += 75 + self.human.has_watch = False + if self.human.money >= target_money: + return + + if self.human.has_tack: + if self._read_yes_no("Will you part with that diamond tie tack? "): + if self._chance(4): + print("It's paste. $25.") + self.human.money += 25 + else: + print("You are now $100 richer.") + self.human.money += 100 + self.human.has_tack = False + if self.human.money >= target_money: + return + + print("Your wad is shot. So long, sucker!") + raise GameOver + + def _settle_bets(self) -> None: + """Move both sides' committed chips into the pot.""" + self.pot += self.human.settle_bet() + self.pot += self.dealer.settle_bet() + + # ------------------------------------------------------------------ + # Showdown + # ------------------------------------------------------------------ + + def _showdown(self) -> None: + """Reveal hands, compare scores, and award the pot.""" + print("\nNow we compare hands:") + + print("My hand:") + print(self.dealer.hand) + dealer_hand_eval = self.dealer.hand.evaluate() + + player_hand_eval = self.human.hand.evaluate() + + print(f"\nYou have {player_hand_eval}") + print(f"And I have {dealer_hand_eval}") + + if dealer_hand_eval == player_hand_eval: + print("The hand is drawn.") + print(f"All ${self.pot} remains in the pot.") + else: + self._award_pot(dealer_wins=dealer_hand_eval > player_hand_eval) + + def _award_pot(self, dealer_wins: bool) -> None: + """Announce the winner, award the pot, and prompt to continue.""" + if dealer_wins: + print("I win.") + self.dealer.win_pot(self.pot) + else: + print("You win.") + self.human.win_pot(self.pot) + print( + f"Now I have $ {self.dealer.money} and you have $ {self.human.money}" + ) + if not self._read_yes_no("Do you wish to continue? "): + raise GameOver + self.pot = 0 # Reset pot after awarding it + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + # pylint: disable=too-many-arguments + def _read_int( + self, + prompt: str, + *, + low: int | None = None, + high: int | None = None, + too_low_msg: str | None = None, + too_high_msg: str | None = None, + ) -> int: + """Read and validate an integer within an optional range.""" + while True: + raw = input(prompt).strip() + if not raw: + continue + try: + val = int(raw) + except ValueError: + continue + + if low is not None and val < low: + if too_low_msg: + print(too_low_msg) + continue + + if high is not None and val > high: + if too_high_msg: + print(too_high_msg) + continue + + return val + + def _read_yes_no(self, prompt: str) -> bool: + """Read yes/no response. Returns True for yes, False for no.""" + while True: + response = input(prompt).strip().lower() + if response in ["y", "yes"]: + return True + if response in ["n", "no"]: + return False + print("Answer yes or no, please.") + + def _chance(self, n: int) -> bool: + """Return True with probability n/10.""" + return random.random() < n / 10 + + def _display_intro(self) -> None: + """Print the welcome banner and loop indefinitely over rounds.""" + print(" POKER") + print(" CREATIVE COMPUTING MORRISTOWN, NEW JERSEY\n\n") + print( + f"Welcome to the casino. We each have ${PokerGame.INITIAL_MONEY}." + ) + print("I will open the betting before the draw; you open after.") + print("To fold bet 0; to check bet .5.") + print("Enough talk -- let's get down to business.") diff --git a/71_Poker/python/players/__init__.py b/71_Poker/python/players/__init__.py new file mode 100644 index 000000000..866e40a52 --- /dev/null +++ b/71_Poker/python/players/__init__.py @@ -0,0 +1,7 @@ +"""Players package initialization.""" + +from .dealer import Dealer +from .human import Human +from .player import Player + +__all__ = ["Player", "Human", "Dealer"] diff --git a/71_Poker/python/players/dealer.py b/71_Poker/python/players/dealer.py new file mode 100644 index 000000000..a3e37d1b7 --- /dev/null +++ b/71_Poker/python/players/dealer.py @@ -0,0 +1,187 @@ +"""Dealer state and decision-making logic.""" + +import random +from enum import Enum, auto + +from cards import Deck +from pokerhand import Hand, HandEvaluation, HandRank + +from .player import Player + + +class Dealer(Player): + """Dealer state: bankroll, strategy, and per-round betting.""" + + class Strategy(Enum): + """The dealer's play style for the current round, set during the opening.""" + + NORMAL = auto() + WEAK = auto() + BLUFFING = auto() + + strategy: Strategy + bet_base: int + was_bluffing: bool + hand_is_weak: bool + bluff_discard_count: int | None + + def __init__(self, money: int) -> None: + """Initialize the computer dealer's starting state.""" + super().__init__(money) + self.strategy = Dealer.Strategy.NORMAL + self.bet_base = 0 + self.was_bluffing = False + self.hand_is_weak = False + self.bluff_discard_count = None + + def receive_new_hand(self, hand: Hand) -> None: + """Reset per-round state and receive a new hand.""" + super().receive_new_hand(hand) + self.strategy = Dealer.Strategy.NORMAL + self.bet_base = 0 + self.was_bluffing = False + self.hand_is_weak = False + self.bluff_discard_count = None + + def decide_postdraw_bet(self) -> None: + """Update bluffing state, evaluate hand and set betting tier.""" + self.was_bluffing = self.strategy == Dealer.Strategy.BLUFFING + + result = self.hand.evaluate() + rank = result.hand_rank + self.bet = 0 + self.hand_is_weak = self._is_hand_weak(result, is_redeal=True) + + if self.was_bluffing: + self.bet_base = 28 + elif self.hand_is_weak: + self.bet_base = 1 + elif rank < HandRank.THREE_OF_A_KIND: + self.bet_base = 2 + if self._chance(1): + self.bet_base = 19 + elif rank <= HandRank.FLUSH: # THREE_OF_A_KIND, STRAIGHT, FLUSH + self.bet_base = 19 + if self._chance(1): + self.bet_base = 11 + else: # FULL_HOUSE, FOUR_OF_A_KIND + self.bet_base = 2 + + def _is_hand_weak(self, result: HandEvaluation, is_redeal: bool) -> bool: + """Check whether the hand qualifies as 'weak'.""" + # Original logic: IF Z>5 THEN Weak. + # HandRank 1-4 are Schmaltz, Partial Straight, Pair, Two-Pair. + if result.hand_rank < HandRank.THREE_OF_A_KIND: + # Partial Straight is only weak on re-evaluation + if result.hand_rank == HandRank.PARTIAL_STRAIGHT: + return is_redeal + return True + return False + + def get_opening_action(self) -> Player.Action: + """Evaluate hand and set the dealer's pre-draw opening bet and strategy.""" + result = self.hand.evaluate() + + if self._is_hand_weak(result, is_redeal=False): + strat, base, discard, action = self._decide_weak_opening() + else: + strat, base, discard, action = self._decide_normal_opening(result) + + self.strategy = strat + self.bet_base = base + self.bluff_discard_count = discard + + if action == Player.Action.RAISE: + self.bet = self.bet_base + random.randint(0, 9) + else: + self.bet = 0 + + return action + + def _decide_weak_opening( + self, + ) -> tuple[Strategy, int, int | None, Player.Action]: + """Pure logic for weak-hand opening decisions.""" + if self._chance(8): + return Dealer.Strategy.BLUFFING, 23, 2, Player.Action.RAISE + if self._chance(8): + return Dealer.Strategy.BLUFFING, 23, 1, Player.Action.RAISE + if self._chance(9): + return Dealer.Strategy.WEAK, 1, None, Player.Action.CHECK + return Dealer.Strategy.BLUFFING, 23, 0, Player.Action.RAISE + + def _decide_normal_opening( + self, result: HandEvaluation + ) -> tuple[Strategy, int, int | None, Player.Action]: + """Pure logic for normal-hand opening decisions.""" + rank = result.hand_rank + if rank < HandRank.THREE_OF_A_KIND: + if self._chance(8): + return Dealer.Strategy.NORMAL, 0, None, Player.Action.CHECK + return Dealer.Strategy.BLUFFING, 23, None, Player.Action.RAISE + + # Strong hand (THREE_OF_A_KIND and above) + if rank > HandRank.FULL_HOUSE: # FOUR_OF_A_KIND + base = 2 if self._chance(9) else 35 + else: + base = 35 + return Dealer.Strategy.NORMAL, base, None, Player.Action.RAISE + + def discard_and_draw(self, deck: Deck) -> int: + """Evaluate hand and replace the dealer's discard cards.""" + if self.bluff_discard_count is not None: + count = self.bluff_discard_count + if count == 0: + return 0 + ranked = sorted(range(5), key=lambda i: self.hand[i].rank.value) + for i in ranked[:count]: + self.replace_card(i, deck.deal()) + return count + + result = self.hand.evaluate() + for i in result.discard_indices: + self.replace_card(i, deck.deal()) + return len(result.discard_indices) + + def get_check_response(self) -> Player.Action: + """Respond to a player's check. Returns the dealer's action.""" + if not self.was_bluffing and self.hand_is_weak: + return Player.Action.CHECK + + self.bet = self.bet_base + random.randint(0, 9) + return Player.Action.RAISE + + def get_raise_response(self, player_paid: int) -> Player.Action: + """Respond to a player's raise. Returns the dealer's action.""" + if self.bet_base != 1: + if player_paid > 3 * self.bet_base: + if self.bet_base == 2 and self._chance(1): + return self._raise(player_paid) + return self._call(player_paid) + return self._raise(player_paid) + + if player_paid > 5: + return Player.Action.FOLD + + if player_paid > 3: + return self._call(player_paid) + + return self._raise(player_paid) + + def _call(self, player_paid: int) -> Player.Action: + """Dealer calls the player's bet.""" + self.bet = player_paid + return Player.Action.CALL + + def _raise(self, player_paid: int) -> Player.Action: + """Raise the player's bet.""" + raise_amount = player_paid - self.bet + random.randint(0, 9) + if raise_amount <= 0: + return self._call(player_paid) + + self.bet = player_paid + raise_amount + return Player.Action.RAISE + + def _chance(self, n: int) -> bool: + """Return True with probability n/10.""" + return random.random() < n / 10 diff --git a/71_Poker/python/players/human.py b/71_Poker/python/players/human.py new file mode 100644 index 000000000..b496e1ceb --- /dev/null +++ b/71_Poker/python/players/human.py @@ -0,0 +1,21 @@ +"""Human player state and behavior.""" + +from .player import Player + + +class Human(Player): + """ + The human player in the game. + + Note: As a state container, this class has no logic methods; + decision-making is provided by the user via the game controller. + """ + + has_watch: bool + has_tack: bool + + def __init__(self, money: int) -> None: + """Initialize the human player with starting assets.""" + super().__init__(money) + self.has_watch = True + self.has_tack = True diff --git a/71_Poker/python/players/player.py b/71_Poker/python/players/player.py new file mode 100644 index 000000000..e5925641d --- /dev/null +++ b/71_Poker/python/players/player.py @@ -0,0 +1,51 @@ +"""Base Player class for both Human and Dealer.""" + +from enum import Enum, auto + +from cards import Card +from pokerhand import Hand + + +class Player: + """Base class representing a participant in the poker game.""" + + money: int + bet: int + hand: Hand + + class Action(Enum): + """Standard actions a player can take.""" + + FOLD = auto() + CHECK = auto() + CALL = auto() + RAISE = auto() + + def __init__(self, money: int) -> None: + """Initialize the player with a starting bankroll.""" + self.money = money + self.bet = 0 + + def receive_new_hand(self, hand: Hand) -> None: + """Reset per-round state and receive a new hand.""" + self.bet = 0 + self.hand = hand + + def pay_ante(self, amount: int) -> None: + """Pay the ante.""" + self.money -= amount + + def win_pot(self, amount: int) -> None: + """Add the pot to the player's money.""" + self.money += amount + + def settle_bet(self) -> int: + """Move the current bet out of the player's money. Returns the bet amount.""" + amount = self.bet + self.money -= amount + self.bet = 0 + return amount + + def replace_card(self, index: int, card: Card) -> None: + """Replace a card in the player's hand.""" + self.hand[index] = card diff --git a/71_Poker/python/poker.py b/71_Poker/python/poker.py new file mode 100644 index 000000000..f027432bf --- /dev/null +++ b/71_Poker/python/poker.py @@ -0,0 +1,38 @@ +""" +From: BASIC Computer Games (1978) +Edited by David H. Ahl + +You and the computer are opponents in this game of draw poker. +At the start of the game, each player is given $200. The game +ends when either player runs out of money, although if you go +broke the computer will offer to buy back your wristwatch or +diamond tie tack. + +The computer opens the betting before the draw; you open the +betting after the draw. If you don't have a hand that's worth +anything and you want to fold, bet 0. Prior to the draw, to +check the draw, you may bet .5. Of course, if the computer has +made a bet, you must match it in order to draw or, if you have +a good hand, you may raise the bet at any time. + +The author is A. Christopher Hall of Trinity College, +Hartford, Connecticut. + + +Python port by Alex Conconi, 2026. +""" + +from game import GameOver, PokerGame + + +def main() -> None: + """Entry point for the poker game application.""" + game = PokerGame() + try: + game.run() + except GameOver: + pass + + +if __name__ == "__main__": + main() diff --git a/71_Poker/python/pokerhand/__init__.py b/71_Poker/python/pokerhand/__init__.py new file mode 100644 index 000000000..3de94bf24 --- /dev/null +++ b/71_Poker/python/pokerhand/__init__.py @@ -0,0 +1,7 @@ +"""Hand package initialization.""" + +from .evaluation import HandEvaluation +from .hand import Hand +from .rank import HandRank + +__all__ = ["Hand", "HandEvaluation", "HandRank"] diff --git a/71_Poker/python/pokerhand/evaluation.py b/71_Poker/python/pokerhand/evaluation.py new file mode 100644 index 000000000..c2e33fdbf --- /dev/null +++ b/71_Poker/python/pokerhand/evaluation.py @@ -0,0 +1,206 @@ +"""Hand evaluation logic.""" + +from collections import Counter +from collections.abc import Callable +from typing import TYPE_CHECKING + +from cards.card import Card, CardRank + +from .rank import HandRank + +if TYPE_CHECKING: + from .hand import Hand + + +class HandEvaluation: + """ + Evaluator and Result container for a poker hand. + + When initialized with a Hand, it immediately performs the evaluation + and stores the resulting rank, high card, and recommended discards. + """ + + hand_rank: HandRank + high_card: Card + discard_indices: list[int] + + def __init__(self, hand: "Hand"): + """Perform evaluation of the provided hand.""" + self.discard_indices = [] + self._evaluate(hand) + + def __str__(self) -> str: + rank = self.hand_rank + card = self.high_card + + match rank: + case HandRank.SCHMALTZ | HandRank.PARTIAL_STRAIGHT: + return f"{rank}, {card.rank} high" + case ( + HandRank.PAIR + | HandRank.TWO_PAIR + | HandRank.THREE_OF_A_KIND + | HandRank.FULL_HOUSE + | HandRank.FOUR_OF_A_KIND + ): + return f"{rank} {card.rank}'s" + case HandRank.STRAIGHT: + return f"{rank}, {card.rank} high" + case HandRank.FLUSH: + return f"{rank} {card.suit}" + case _: + return f"{rank} {card.rank}" + + def __lt__(self, other: "HandEvaluation") -> bool: + return (self.hand_rank, self.high_card.rank.value) < ( + other.hand_rank, + other.high_card.rank.value, + ) + + def __eq__(self, other: object) -> bool: + # this object could be compared to any other object with == + if not isinstance(other, HandEvaluation): + return NotImplemented + return (self.hand_rank, self.high_card.rank.value) == ( + other.hand_rank, + other.high_card.rank.value, + ) + + class _EvaluationContext: # pylint: disable=too-few-public-methods + """Intermediate calculations for hand evaluation.""" + + def __init__(self, hand: "Hand"): + self.cards = hand.cards + self.rank_indexed = sorted( + enumerate(self.cards), key=lambda item: item[1].rank.value + ) + + # Frequency analysis + rank_counts = Counter(c.rank for c in self.cards) + sorted_groups = sorted( + rank_counts.items(), + key=lambda rc: (rc[1], rc[0].value), + reverse=True, + ) + self.freq_pattern = tuple(count for _, count in sorted_groups) + self.ranks_by_freq = [rank for rank, _ in sorted_groups] + self.sorted_ranks: list[CardRank] = [ + c.rank for _, c in self.rank_indexed + ] + + def _evaluate(self, hand: "Hand") -> None: + """Internal algorithm to determine hand strength.""" + ctx = self._EvaluationContext(hand) + + checks: list[Callable[[HandEvaluation._EvaluationContext], bool]] = [ + self._check_four_of_a_kind, + self._check_full_house, + self._check_flush, + self._check_straight, + self._check_three_of_a_kind, + self._check_two_pair, + self._check_pair, + self._check_partial_straight, + self._check_schmaltz, + ] + + for check in checks: + if check(ctx): + break + + def _check_four_of_a_kind(self, ctx: _EvaluationContext) -> bool: + if ctx.freq_pattern != (4, 1): + return False + self.hand_rank = HandRank.FOUR_OF_A_KIND + self.high_card = next( + c + for _, c in reversed(ctx.rank_indexed) + if c.rank == ctx.ranks_by_freq[0] + ) + return True + + def _check_full_house(self, ctx: _EvaluationContext) -> bool: + if ctx.freq_pattern != (3, 2): + return False + self.hand_rank = HandRank.FULL_HOUSE + self.high_card = next( + c + for _, c in reversed(ctx.rank_indexed) + if c.rank == ctx.ranks_by_freq[0] + ) + return True + + def _check_flush(self, ctx: _EvaluationContext) -> bool: + if len({c.suit for c in ctx.cards}) != 1: + return False + self.hand_rank = HandRank.FLUSH + self.high_card = max(ctx.cards, key=lambda c: c.rank) + return True + + def _check_straight(self, ctx: _EvaluationContext) -> bool: + if ( + len(set(ctx.sorted_ranks)) == 5 + and ctx.sorted_ranks[4].value - ctx.sorted_ranks[0].value == 4 + ): + self.hand_rank = HandRank.STRAIGHT + self.high_card = ctx.rank_indexed[4][1] + return True + return False + + def _check_three_of_a_kind(self, ctx: _EvaluationContext) -> bool: + if ctx.freq_pattern != (3, 1, 1): + return False + self.hand_rank = HandRank.THREE_OF_A_KIND + self.high_card = next( + c + for _, c in reversed(ctx.rank_indexed) + if c.rank == ctx.ranks_by_freq[0] + ) + self.discard_indices = [idx for idx, _ in ctx.rank_indexed[0:3]] + return True + + def _check_two_pair(self, ctx: _EvaluationContext) -> bool: + if ctx.freq_pattern != (2, 2, 1): + return False + self.hand_rank = HandRank.TWO_PAIR + self.high_card = next( + c + for _, c in reversed(ctx.rank_indexed) + if c.rank == ctx.ranks_by_freq[0] + ) + keep = {ctx.ranks_by_freq[0], ctx.ranks_by_freq[1]} + self.discard_indices = [ + i for i, c in enumerate(ctx.cards) if c.rank not in keep + ] + return True + + def _check_pair(self, ctx: _EvaluationContext) -> bool: + if ctx.freq_pattern != (2, 1, 1, 1): + return False + self.hand_rank = HandRank.PAIR + self.high_card = next( + c + for _, c in reversed(ctx.rank_indexed) + if c.rank == ctx.ranks_by_freq[0] + ) + self.discard_indices = [idx for idx, _ in ctx.rank_indexed[0:3]] + return True + + def _check_partial_straight(self, ctx: _EvaluationContext) -> bool: + if ctx.sorted_ranks[3].value - ctx.sorted_ranks[0].value == 3: + self.hand_rank = HandRank.PARTIAL_STRAIGHT + self.high_card = ctx.rank_indexed[3][1] + self.discard_indices = [ctx.rank_indexed[4][0]] + return True + if ctx.sorted_ranks[4].value - ctx.sorted_ranks[1].value == 3: + self.hand_rank = HandRank.PARTIAL_STRAIGHT + self.high_card = ctx.rank_indexed[4][1] + self.discard_indices = [ctx.rank_indexed[0][0]] + return True + return False + + def _check_schmaltz(self, ctx: _EvaluationContext) -> bool: + self.hand_rank = HandRank.SCHMALTZ + self.high_card = ctx.rank_indexed[4][1] + self.discard_indices = [idx for idx, _ in ctx.rank_indexed[0:4]] + return True diff --git a/71_Poker/python/pokerhand/hand.py b/71_Poker/python/pokerhand/hand.py new file mode 100644 index 000000000..aef598ace --- /dev/null +++ b/71_Poker/python/pokerhand/hand.py @@ -0,0 +1,39 @@ +"""Hand implementation.""" + +from collections.abc import Iterator + +from cards.card import Card + +from .evaluation import HandEvaluation + + +class Hand: + """A 5-card poker hand that acts as a simple data container.""" + + def __init__(self, cards: list[Card]): + if len(cards) != 5: + raise ValueError("Hand must have exactly 5 cards") + self.cards = list(cards) + + def evaluate(self) -> HandEvaluation: + """Factory method to return a hand evaluation.""" + return HandEvaluation(self) + + def __len__(self) -> int: + return len(self.cards) + + def __iter__(self) -> Iterator[Card]: + return iter(self.cards) + + def __getitem__(self, i: int) -> Card: + return self.cards[i] + + def __setitem__(self, i: int, card: Card) -> None: + self.cards[i] = card + + def __str__(self) -> str: + """Return a multi-line formatted description of the hand.""" + entries = [f"{i} -- {card}" for i, card in enumerate(self.cards, 1)] + lines = [f" {entries[i]:<32}{entries[i + 1]}" for i in range(0, 4, 2)] + lines.append(f" {entries[4]}") + return "\n".join(lines) diff --git a/71_Poker/python/pokerhand/rank.py b/71_Poker/python/pokerhand/rank.py new file mode 100644 index 000000000..181cec0a1 --- /dev/null +++ b/71_Poker/python/pokerhand/rank.py @@ -0,0 +1,31 @@ +"""Hand rank enumeration.""" + +from enum import IntEnum, auto + + +class HandRank(IntEnum): + """Poker hand ranks ordered from weakest to strongest.""" + + SCHMALTZ = auto() + PARTIAL_STRAIGHT = auto() + PAIR = auto() + TWO_PAIR = auto() + THREE_OF_A_KIND = auto() + STRAIGHT = auto() + FLUSH = auto() + FULL_HOUSE = auto() + FOUR_OF_A_KIND = auto() + + def __str__(self) -> str: + """Return the display name of the rank.""" + return { + HandRank.SCHMALTZ: "Schmaltz", + HandRank.PARTIAL_STRAIGHT: "Schmaltz", + HandRank.PAIR: "a pair of", + HandRank.TWO_PAIR: "Two pair", + HandRank.THREE_OF_A_KIND: "Three", + HandRank.STRAIGHT: "Straight", + HandRank.FLUSH: "a flush in", + HandRank.FULL_HOUSE: "Full house", + HandRank.FOUR_OF_A_KIND: "Four", + }[self] From 27b6df43d5ea21f850986931f516d4ec3e1f3c01 Mon Sep 17 00:00:00 2001 From: aconconi Date: Sun, 8 Mar 2026 17:54:10 +0100 Subject: [PATCH 2/9] Updated readme --- 71_Poker/python/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/71_Poker/python/README.md b/71_Poker/python/README.md index 31a4baa79..b51af75d4 100644 --- a/71_Poker/python/README.md +++ b/71_Poker/python/README.md @@ -2,8 +2,9 @@ Original source downloaded [from Vintage Basic](http://www.vintage-basic.net/gam Conversion to [Python](https://www.python.org/about/) +--- -## Known BASIC bugs — fixed +#### Known BASIC bugs — fixed - **Tie tack unreachable (line 3970):** The condition `IF O/3<>INT(O/3) THEN 4090` is logically inverted. It checks if the @@ -12,7 +13,7 @@ Conversion to [Python](https://www.python.org/about/) even if the player still owns it. The Python port fixes this to restore the original intended experience where both assets can be sold. -## Known BASIC bugs/quirks — kept faithful +#### Known BASIC bugs/quirks — kept faithful - **$50 buy-back materialises from nowhere (lines 3570/3640):** When the player buys back a pawned item, `C=C+50` adds $50 to the From 013768bf4730b0c22889497044721a25bbf0af9b Mon Sep 17 00:00:00 2001 From: aconconi Date: Sun, 8 Mar 2026 18:12:02 +0100 Subject: [PATCH 3/9] CardRank inherits from IntEnum instead of Enum, to fix type issues. --- 71_Poker/python/cards/card.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/71_Poker/python/cards/card.py b/71_Poker/python/cards/card.py index d16747ef1..2491c3430 100644 --- a/71_Poker/python/cards/card.py +++ b/71_Poker/python/cards/card.py @@ -1,7 +1,7 @@ """Card primitives: Suits, Ranks, and the Card class.""" from dataclasses import dataclass -from enum import Enum +from enum import Enum, IntEnum class CardSuit(Enum): @@ -16,7 +16,7 @@ def __str__(self) -> str: return self.name.capitalize() -class CardRank(Enum): +class CardRank(IntEnum): """Card ranks from Two (0) to Ace (12).""" TWO = 0 From 2b21c17b784f4ac6e0ee6865616fb55b4503f6da Mon Sep 17 00:00:00 2001 From: Alex Conconi <4670015+aconconi@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:28:58 +0100 Subject: [PATCH 4/9] Update README.md --- 71_Poker/python/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/71_Poker/python/README.md b/71_Poker/python/README.md index b51af75d4..ae10bb15e 100644 --- a/71_Poker/python/README.md +++ b/71_Poker/python/README.md @@ -4,9 +4,11 @@ Conversion to [Python](https://www.python.org/about/) --- +### Python porting notes + #### Known BASIC bugs — fixed -- **Tie tack unreachable (line 3970):** The condition +- **Tie tack unreachable** (BASIC line 3970): The condition `IF O/3<>INT(O/3) THEN 4090` is logically inverted. It checks if the tie tack has *not* been sold, and if so, incorrectly jumps to the "Bust" message. This makes the tie tack sale permanently unreachable @@ -15,12 +17,12 @@ Conversion to [Python](https://www.python.org/about/) #### Known BASIC bugs/quirks — kept faithful -- **$50 buy-back materialises from nowhere (lines 3570/3640):** +- **$50 buy-back materialises from nowhere** (BASIC lines 3570/3640): When the player buys back a pawned item, `C=C+50` adds $50 to the dealer's stack without deducting from the player. Kept as-is — it models the dealer/pawnbroker liquidating the physical item. -- **Yes/No prompts accept anything (e.g. lines 3550, 3880):** +- **Yes/No prompts accept anything** (e.g. BASIC lines 3550, 3880): `LEFT$(J$,1)="Y"` treats every non-"Y" input as "no" with no re-prompt. The Python port normalises all yes/no prompts to a single `_read_yes_no` that retries on invalid input (matching the stricter From e12a33ed77f21e38b34882e9a639b5af08a1d63e Mon Sep 17 00:00:00 2001 From: aconconi Date: Sun, 8 Mar 2026 18:34:53 +0100 Subject: [PATCH 5/9] Removed redundant initialization of Dealer --- 71_Poker/python/players/dealer.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/71_Poker/python/players/dealer.py b/71_Poker/python/players/dealer.py index a3e37d1b7..0467f4add 100644 --- a/71_Poker/python/players/dealer.py +++ b/71_Poker/python/players/dealer.py @@ -25,15 +25,6 @@ class Strategy(Enum): hand_is_weak: bool bluff_discard_count: int | None - def __init__(self, money: int) -> None: - """Initialize the computer dealer's starting state.""" - super().__init__(money) - self.strategy = Dealer.Strategy.NORMAL - self.bet_base = 0 - self.was_bluffing = False - self.hand_is_weak = False - self.bluff_discard_count = None - def receive_new_hand(self, hand: Hand) -> None: """Reset per-round state and receive a new hand.""" super().receive_new_hand(hand) From 1b2a07b8d9d5e4fd005ec2bf9ab887a1bb266de7 Mon Sep 17 00:00:00 2001 From: aconconi Date: Sun, 8 Mar 2026 18:49:38 +0100 Subject: [PATCH 6/9] Comments --- 71_Poker/python/game.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/71_Poker/python/game.py b/71_Poker/python/game.py index 6e322e9ae..d4363bc97 100644 --- a/71_Poker/python/game.py +++ b/71_Poker/python/game.py @@ -72,6 +72,7 @@ def _play_round(self) -> None: print(self.human.hand) print() + # --- Opening bet --- dealer_action = self.dealer.get_opening_action() if self.dealer.money < self.dealer.bet: self._dealer_try_raise_funds() @@ -92,8 +93,8 @@ def _play_round(self) -> None: f"\nI am taking {draw_count} card{'s' if draw_count != 1 else ''}" ) + # --- Post-draw bet --- self.dealer.decide_postdraw_bet() - if not self._conduct_betting_round(post_draw=True): return From 12ad7f405f10d487104d1f590027710a391f1d15 Mon Sep 17 00:00:00 2001 From: aconconi Date: Sun, 8 Mar 2026 18:51:53 +0100 Subject: [PATCH 7/9] Docstring --- 71_Poker/python/game.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/71_Poker/python/game.py b/71_Poker/python/game.py index d4363bc97..0bda902ac 100644 --- a/71_Poker/python/game.py +++ b/71_Poker/python/game.py @@ -387,7 +387,7 @@ def _chance(self, n: int) -> bool: return random.random() < n / 10 def _display_intro(self) -> None: - """Print the welcome banner and loop indefinitely over rounds.""" + """Print the welcome banner.""" print(" POKER") print(" CREATIVE COMPUTING MORRISTOWN, NEW JERSEY\n\n") print( From 27537f2d72e2f19428fd356eabb52109d61d777f Mon Sep 17 00:00:00 2001 From: aconconi Date: Mon, 9 Mar 2026 15:40:24 +0100 Subject: [PATCH 8/9] betting loop refactored for clarity --- 71_Poker/python/game.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/71_Poker/python/game.py b/71_Poker/python/game.py index 0bda902ac..92d97463a 100644 --- a/71_Poker/python/game.py +++ b/71_Poker/python/game.py @@ -130,35 +130,42 @@ def _player_discard_draw(self) -> None: # ------------------------------------------------------------------ def _conduct_betting_round(self, post_draw: bool) -> bool: - """Core loop for a betting round. Returns True to continue the round.""" + """ + Executes a betting round. + Returns True if the betting phase completed (hand continues). + Returns False if a player folded (hand ends). + """ while True: human_action = self._get_human_action() match human_action: case Player.Action.FOLD: + # Human fold always ends betting loop (hand ends) self._settle_bets() self._award_pot(dealer_wins=True) return False - case Player.Action.CHECK: - if post_draw: - if self._handle_post_draw_check(): - return True - continue - return True - case Player.Action.CALL: + # Human call always ends betting loop (proceed to next phase) self._settle_bets() return True + case Player.Action.CHECK: + # Pre-draw: human check ends human's turn. + # Post-draw: dealer gets a chance to bet/check back. + if not post_draw or self._handle_human_post_draw_check(): + return True + # If dealer bet, we fall through and loop for human response + case Player.Action.RAISE: if self._handle_human_raise(): return True + # If dealer re-raised, we fall through and loop for human response case _: - raise ValueError("Unknown player action.") + raise ValueError(f"Unknown player action: {human_action}") - def _handle_post_draw_check(self) -> bool: + def _handle_human_post_draw_check(self) -> bool: """Process dealer response to human check. Returns True if phase ends.""" dealer_action = self.dealer.get_check_response() if self.dealer.money < self.dealer.bet: @@ -196,7 +203,7 @@ def _handle_human_raise(self) -> bool: return False case _: - raise ValueError("Unknown dealer action.") + raise ValueError(f"Unknown dealer action: {dealer_action}") def _get_human_action(self) -> Player.Action: """Prompt user for a bet and return the resulting Action.""" From e9050e3db5b7acdbc5141b8cf63327e96309f66f Mon Sep 17 00:00:00 2001 From: Alex Conconi <4670015+aconconi@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:29:24 +0100 Subject: [PATCH 9/9] Update README.md --- 71_Poker/python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/71_Poker/python/README.md b/71_Poker/python/README.md index ae10bb15e..bc58f0839 100644 --- a/71_Poker/python/README.md +++ b/71_Poker/python/README.md @@ -1,6 +1,6 @@ Original source downloaded [from Vintage Basic](http://www.vintage-basic.net/games.html) -Conversion to [Python](https://www.python.org/about/) +Conversion to [Python](https://www.python.org/about/) by Alex Conconi, 2026. ---