6
\$\begingroup\$

This is an OOP version of Lastest version of my Blackjack game. Also, it now uses PostgreSQL as database.

import random
import typing as t
from enum import Enum
from functools import partial
import os
from getpass import getpass
from re import match
import bcrypt
import psycopg2
import attr


lock = partial(attr.s, auto_attribs=True, slots=True)
State = Enum("State", "IDLE ACTIVE STAND BUST")


def clear_console():
    os.system("cls" if os.name == "nt" else "clear")


def start_choice():
    while True:
        ans = input(
            "\nWhat do you want to do?\n[1] - Start playing\n[2] - Display the top\n> "
        )
        if ans in ("1", "2"):
            return ans == "1"


def ask_question(question):
    while True:
        print(f"{question} (y/n)?")
        ans = input("> ").casefold()
        if ans in ("y", "n"):
            return ans == "y"


def ask_bet(budget):
    clear_console()
    print(f"Money: ${budget}")
    print("How much money do you want to bet?")
    while True:
        money_bet = input("> ")
        try:
            cash_bet = int(money_bet)
        except ValueError:
            cash_bet = -1
        if budget >= cash_bet > 0:
            return cash_bet
        print("Please input a valid bet.")


def get_user_credentials():
    clear_console()
    while True:
        email = input("Email address (max. 255 chars.):\n> ")
        password = getpass("Password (max. 1000 chars.):\n> ").encode("utf8")
        hashed_pw = bcrypt.hashpw(password, bcrypt.gensalt()).decode("utf8")
        if len(email) < 255 and len(password) < 1000:
            if match(r"[^@]+@[^@]+\.[^@]+", email):
                return email, password, hashed_pw
            print("Please input a valid email address.")


def build_deck():
    suits = ["Hearts", "Clubs", "Diamonds", "Spades"]
    values = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
    cards = [Card(value, suit) for value in values for suit in suits]
    return cards


@lock
class Card:

    value: str
    suit: str

    def score(self):
        if self.value in "JQK":
            return 10
        elif self.value == "A":
            return 1
        else:
            return int(self.value)

    def __str__(self):
        return f"{self.value} of {self.suit}"


@lock
class Shoe:

    cards: t.List[Card] = attr.ib(factory=build_deck)

    def shuffle(self):
        random.shuffle(self.cards)

    def draw_card(self):
        return self.cards.pop()

    def __str__(self):
        cards = [str(c) for c in self.cards]
        return str(cards)


@lock
class Hand:
    # A collection of cards that a player get from the dealer in a game
    cards: t.List[Card] = attr.ib(default=[])

    def add(self, card):
        self.cards.append(card)

    def score(self):
        # Value of cards at hand
        total = sum(card.score() for card in self.cards)

        if any(card.value == "A" for card in self.cards) and total <= 11:
            total += 10

        return total

    def __str__(self):
        return "{} ({})".format(
            "".join("[{}]".format(card.value) for card in self.cards), self.score()
        )


@lock
class Player:

    budget: int  # Number of money for bets
    bet: int = attr.ib(default=None)  # Money bet
    hand: Hand = attr.ib(factory=Hand)  # Player's hand
    state: State = attr.ib(default=State.IDLE)  # can be IDLE, ACTIVE, STAND or BUST

    def player_bet(self):
        if self.is_broke():
            raise Exception("Unfortunately you don't have any money.")
        self.bet = ask_bet(self.budget)

    """ Update self.state after self.hit
        If player busted, self.state = State.BUST, etc.
    """

    def update(self):
        hand_score = self.hand.score()
        if hand_score > 21:
            self.state = State.BUST
        elif hand_score == 21:
            self.state = State.STAND
        else:
            self.state = State.ACTIVE

    def is_busted(self):
        return self.state == State.BUST

    def is_standing(self):
        return self.state == State.STAND

    def is_idle(self):
        return self.state == State.IDLE

    def is_broke(self):
        return self.budget == 0

    def hit(self, dealer):
        # Ask dealer to add a card to the hand (at their turn)
        card = dealer.draw_card()
        self.hand.add(card)

    def play(self, dealer):
        if ask_question("Do you want to hit"):
            # Player hits
            self.hit(dealer)
            self.update()
        else:
            self.state = State.STAND

    def __str__(self):
        return f"Player Info:\nBudget: {self.budget}\nMoney bet: {self.bet}\nHand: {self.hand}"


@lock
class Dealer:

    shoe: Shoe = attr.ib(factory=Shoe)
    hand: Hand = attr.ib(factory=Hand)
    state: State = attr.ib(default=State.IDLE)

    def draw_card(self):  # Delegate method
        card = self.shoe.draw_card()
        return card

    def hit(self):
        card = self.draw_card()
        self.hand.add(card)

    def update(self):
        hand_score = self.hand.score()
        if hand_score > 21:
            self.state = State.BUST
        elif hand_score >= 17:
            self.state = State.STAND
        else:
            self.state = State.ACTIVE

    def is_busted(self):
        return self.state == State.BUST

    def is_standing(self):
        return self.state == State.STAND

    def is_idle(self):
        return self.state == State.IDLE

    def play(self):
        if self.hand.score() < 17:
            self.hit()
            self.update()

    """ In this method, the dealer and player enter a loop
        In which the player hits a card from the dealer until it stands or busts
    """

    def deal(self, player, game):
        while True:
            player.play(self)
            game.display_info()
            if player.is_busted() or player.is_standing():
                break

    def display_cards(self, player, game):
        if game.is_finished():
            return f"Dealer Info:\nHand:{self.hand}"
        elif player.state == State.ACTIVE:
            return f"Dealer Info:\nHand: [{self.hand.cards[0]}][?]"


@lock
class Database:

    sql_id: int = attr.ib(default=None)
    email: str = attr.ib(default=None)
    password: str = attr.ib(default=None)
    hashed_pw: str = attr.ib(default=None)
    budget: int = attr.ib(default=None)
    conn: t.Any = attr.ib(
        default=psycopg2.connect(
            dbname="blackjack", user="postgres", password="12344321", host="localhost"
        )
    )
    cur: t.Any = attr.ib(default=None)

    def check_account(self):
        self.cur.execute("SELECT id FROM users WHERE email=%s", (self.email,))
        return bool(self.cur.fetchone())

    def login(self):
        self.cur.execute("SELECT password FROM users WHERE email=%s", (self.email,))
        credentials = self.cur.fetchone()
        correct_hash = credentials[0].encode("utf8")
        if bcrypt.checkpw(self.password, correct_hash):
            print("You have successfully logged-in!")
        else:
            raise Exception("You have failed logging-in!")

    def register(self):
        self.cur.execute(
            "INSERT into users (email, password) VALUES (%s, %s)",
            (self.email, self.hashed_pw),
        )

    def initialize(self):
        with self.conn:
            self.email, self.password, self.hashed_pw = get_user_credentials()
            self.cur = self.conn.cursor()
            checked = self.check_account()
            if checked:
                self.login()
            else:
                self.register()
                print("You have successfully registered and received $1000 as a gift!")
            self.cur.execute(
                "SELECT ID, budget FROM users WHERE email=%s", (self.email,)
            )
            sql_id_budget = self.cur.fetchone()
            self.sql_id = sql_id_budget[0]
            self.budget = sql_id_budget[1]

    def display_top(self):
        self.cur.execute("SELECT email, budget FROM users ORDER BY budget DESC")
        top = self.cur.fetchall()
        places = range(1, len(top) + 1)
        for (a, b), i in zip(top, places):
            print(f"{i}. {a} - ${b}")

    def update_budget(self):
        self.cur.execute(
            "UPDATE users SET budget=%s WHERE id=%s", (self.budget, self.sql_id)
        )
        self.conn.commit()


@lock
class Game:

    player: Player
    dealer: Dealer = attr.ib(factory=Dealer)

    def reset_attributes(self):
        self.player.hand.cards = []
        self.player.state = State.IDLE
        self.dealer.hand.cards = []
        self.dealer.state = State.IDLE
        self.dealer.shoe = Shoe()

    def open(self):

        self.player.player_bet()

        self.dealer.shoe.shuffle()

        c1 = self.dealer.draw_card()
        c2 = self.dealer.draw_card()
        self.player.hand = Hand([c1, c2])
        self.player.update()  # Update player state

        # The dealer is the last one to get cards
        c1 = self.dealer.draw_card()
        c2 = self.dealer.draw_card()
        self.dealer.hand = Hand([c1, c2])
        self.dealer.update()

        self.display_info()

    def is_finished(self):
        if self.dealer.hand.score() >= 21:
            return True
        if self.player.is_busted() or self.player.is_standing():
            return True

    """ Pay/charge the player according to cards result
        Reset hands, states, shoe
    """

    def close(self):
        dealer_score = self.dealer.hand.score()
        if not self.player.is_busted():

            if self.dealer.state == State.BUST:
                self.player.budget += self.player.bet * 2
            else:
                if self.player.hand.score() < dealer_score:
                    self.player.budget -= self.player.bet
                elif self.player.hand.score() > dealer_score:
                    self.player.budget += self.player.bet * 2
        else:
            self.player.budget -= self.player.bet

        self.display_info()

    def run(self):
        # Run a full game, from open() to close()
        self.open()

        # If the dealer has a blackjack, close the game
        if self.is_finished():
            self.close()
            return

        # The dealer deals with the player
        self.dealer.deal(self.player, self)

        # Now the dealer's turn to play ...
        while True:
            self.dealer.play()
            if self.dealer.is_busted() or self.dealer.is_standing():
                break

        self.close()

    def display_info(self):
        clear_console()
        print(f"{self.player}\n")
        print(f"{self.dealer.display_cards(self.player, self)}\n")
        player_score = self.player.hand.score()
        dealer_score = self.dealer.hand.score()
        if player_score == 21:
            print("Blackjack! You won!")
        elif dealer_score == 21:
            print("Dealer has got a blackjack. You lost!")
        elif self.player.is_busted():
            print("Busted! You lost!")
        elif self.player.is_standing():
            if self.dealer.is_busted():
                print("Dealer busted! You won!")
            elif player_score > dealer_score:
                print("You beat the dealer! You won!")
            elif player_score < dealer_score:
                print("Dealer has beaten you. You lost!")
            else:
                print("Push. Nobody wins or losses.")


def main():
    database = Database()
    database.initialize()
    if start_choice():
        player = Player(database.budget)
        game = Game(player)
        playing = True
        while playing:
            game.run()
            database.budget = player.budget
            database.update_budget()
            playing = ask_question("\nDo you want to play again")
            if playing:
                game.reset_attributes()
            else:
                database.cur.close()
    else:
        database.display_top()


if __name__ == "__main__":
    main()
\$\endgroup\$
3
+50
\$\begingroup\$

Here are a few things that caught my eye while quickly looking through your code:


Imports

As per the official Style Guide for Python Code (aka PEP8), imports should best be grouped. Imports from the standard library come first, then third-party modules, followed by any library/application specific imports. The groups should be seperated by blank lines. In your particular case, that might look like this:

import os
import random
import typing as t
from enum import Enum
from functools import partial
from getpass import getpass
from re import match

import attr
import bcrypt
import psycopg2

The order of the imports has also changed slightly. That's because I left grouping and sorting the imports to the according function of Visual Studio Code, which under the hood uses isort to automate the process.

Documentation

There are a few loose text blocks, that are likely meant to be seen as method documentation. I'm talking about the triple-quoted text above Player.update as well as Dealer.deal and Game.close. To really work as a documentation string, they should be placed properly indented inside the function body like so:

def update(self):
    """ Update self.state after self.hit

    If player busted, self.state = State.BUST, etc.
    """
    ...
def deal(self, player, game):
    """ In this method, the dealer and player enter a loop in which the 
    player gets a card from the dealer until it stands or busts.
    """
    ...

These now follow the style guide recommendations on how to write proper docstrings. Python's help(...) as well as most Python IDEs will now easily pick it up to help you out.

In general, you should think about adding more documentation to your code. Often just a one-line description is sufficient to describe what the function is supposed to do. If there is more to say, say more. If you ever came back to your code after a longer pause, future-you will be really really grateful for the extra effort you spent back then.

Building the deck

build_deck uses a double list comprehension to generate all possible combinations of values and suits. This function can also be rewritten using itertools.product:

from itertools import product

def build_deck():
    suits = ("Hearts", "Clubs", "Diamonds", "Spades")
    values = ("2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A")
    return [Card(value, suit) for value, suit in product(values, suits)]

It doesn't make a huge difference here, but it's always good to now about itertools, especially if you ever find yourself in a situation where you'd have to combine more than two or three iterables. I also changed suits and values to be tuples instead of lists, which is IMHO a nice way to express that you don't want to modify them, because tuples are immutable.

Whatever lock is

You define lock to be a partial version of attr.s, but I'm not entirely sure if lock is a particularly good name for it. As I usually don't work with the attr module it could be just me, but at the moment, lock does not really seem to fit here. IMHO the name strongly hints towards authentication, but does not do anything in that regard if I'm not totally mistaken.

ask_question

casefold() does seem to be a little bit "overpowered" here. A simple lower() would suffice and be less suprising to us mere mortal Python programmers out here looking at your code ;-)

Card.score

Card.score breaks the pattern of listing all the elements you are willing to accept explicitly and give them all as a single string instead. I don't have strong arguments why you should or should not use either of those, but it's probably best to stick to one of them. Personally, I prefer the tuple version.

Credentials in code

Your Database class has all the database credentials hard-coded. That's usually not a great idea for several reasons. The first one is that if you ever push your code to a public repository (or Code Review), your credentials will also be public. That's usually not what you want. If you now think, that you will remember to remove them before doing that: You won't! The second one is that if you ever decided to change them or give the game code to someone else, you or, even worse they, will have to touch the code.

There are also several ways on how to improve this, where I think putting the credentials in environment variables or an external configuration file are the most commonly used ones. The first variant might look like this:

conn: t.Any = attr.ib(default=psycopg2.connect(dbname=os.environ["BLACKJACK_DB_NAME"],
                                               user=os.environ["BLACKJACK_DB_USER"],
                                               password=os.environ["BLACKJACK_DB_PASS"],
                                               host=os.environ["BLACKJACK_DB_HOST"]))

That's it for now. Maybe I will find the time to extend the answer in a few days. Till then: Happy coding and good luck with the reviews!

\$\endgroup\$
  • \$\begingroup\$ Hey, thank you for the tips, can't wait to see the extended answer! \$\endgroup\$ – Maria Laura Jul 26 at 22:40
  • \$\begingroup\$ I awarded you, but I still hope you'll make that extended answer. \$\endgroup\$ – Maria Laura Aug 1 at 21:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.