Add Blackjack game: rules, card rendering, game loop and tests
Implement the playable console game for gateway MIL-002. - rules.py: deal_card, calculate_score (blackjack = 0, aces drop 11 -> 1) and compare with an Outcome enum; no console I/O - display.py: hand rendering as plain-text rank plus suit emoji (keycap emoji rendered as boxes in common terminals), score formatting and outcome messages - art.py: course logo shown at the start of every game - game.py: play_game (hit/stand, dealer draws below 17), play (restart loop, console clear, logo) and ask_yes_no with re-prompt; read, write and draw are injected so games can be scripted - __main__.py: python -m blackjack entry point, exits cleanly on Ctrl+C/Ctrl+D - constants.py: card ranks and suits, prompts and messages - tests: 32 new unit and scripted end-to-end tests (35 in total) - README: run instructions for python -m blackjack Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 Task: MIL-002#7 Task: MIL-002#8
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""!
|
||||
@file __main__.py
|
||||
@brief Entry point: python -m blackjack.
|
||||
"""
|
||||
|
||||
from blackjack.constants import MESSAGE_GOODBYE
|
||||
from blackjack.game import play
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""!
|
||||
@brief Run the game; leave quietly on Ctrl+C or Ctrl+D.
|
||||
"""
|
||||
try:
|
||||
play()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print(f"\n{MESSAGE_GOODBYE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""!
|
||||
@file art.py
|
||||
@brief ASCII art shown at the start of every game.
|
||||
"""
|
||||
|
||||
## The game logo from the course assignment.
|
||||
LOGO: str = r"""
|
||||
.------. _ _ _ _ _
|
||||
|A_ _ |. | | | | | | (_) | |
|
||||
|( \/ ).-----. | |__ | | __ _ ___| | ___ __ _ ___| | __
|
||||
| \ /|K /\ | | '_ \| |/ _` |/ __| |/ / |/ _` |/ __| |/ /
|
||||
| \/ | / \ | | |_) | | (_| | (__| <| | (_| | (__| <
|
||||
`-----| \ / | |_.__/|_|\__,_|\___|_|\_\ |\__,_|\___|_|\_\\
|
||||
| \/ K| _/ |
|
||||
`------' |__/
|
||||
"""
|
||||
+46
-13
@@ -24,17 +24,50 @@ BLACKJACK_MARKER: int = 0
|
||||
## Unlimited deck: cards are never removed. 11 is the ace, 10 covers 10/J/Q/K.
|
||||
CARDS: list[int] = [11, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10]
|
||||
|
||||
## Emoji face for each card value.
|
||||
CARD_FACES: dict[int, str] = {
|
||||
ACE_HIGH: "\U0001f170️",
|
||||
ACE_LOW: "\U0001f170️",
|
||||
2: "2️⃣",
|
||||
3: "3️⃣",
|
||||
4: "4️⃣",
|
||||
5: "5️⃣",
|
||||
6: "6️⃣",
|
||||
7: "7️⃣",
|
||||
8: "8️⃣",
|
||||
9: "9️⃣",
|
||||
10: "\U0001f51f",
|
||||
## Rank text for each card value; plain text renders in every terminal.
|
||||
CARD_RANKS: dict[int, str] = {
|
||||
ACE_HIGH: "A",
|
||||
ACE_LOW: "A",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
5: "5",
|
||||
6: "6",
|
||||
7: "7",
|
||||
8: "8",
|
||||
9: "9",
|
||||
10: "10",
|
||||
}
|
||||
|
||||
## Suit emoji shown after the rank; cards in a hand cycle through them.
|
||||
CARD_SUITS: tuple[str, ...] = ("♠️", "♥️", "♦️", "♣️")
|
||||
|
||||
## Answer that means yes at a yes/no prompt.
|
||||
ANSWER_YES: str = "y"
|
||||
|
||||
## Answer that means no at a yes/no prompt.
|
||||
ANSWER_NO: str = "n"
|
||||
|
||||
## Prompt asking the player whether to draw another card.
|
||||
PROMPT_HIT: str = "Type 'y' to get another card, type 'n' to pass: "
|
||||
|
||||
## Prompt asking the player whether to play a game.
|
||||
PROMPT_RESTART: str = "Do you want to play a game of Blackjack? Type 'y' or 'n': "
|
||||
|
||||
## Message shown after an answer that is neither yes nor no.
|
||||
MESSAGE_INVALID_ANSWER: str = "Please answer 'y' or 'n'."
|
||||
|
||||
## Message shown when the player leaves the game.
|
||||
MESSAGE_GOODBYE: str = "Goodbye!"
|
||||
|
||||
## Label shown instead of a score when a hand is a blackjack.
|
||||
LABEL_BLACKJACK: str = "Blackjack"
|
||||
|
||||
## Outcome messages.
|
||||
MESSAGE_DRAW: str = "Draw \U0001f643"
|
||||
MESSAGE_WIN: str = "You win \U0001f603"
|
||||
MESSAGE_WIN_BLACKJACK: str = "Win with a Blackjack \U0001f60e"
|
||||
MESSAGE_WIN_DEALER_BUST: str = "Opponent went over. You win \U0001f601"
|
||||
MESSAGE_LOSE: str = "You lose \U0001f624"
|
||||
MESSAGE_LOSE_DEALER_BLACKJACK: str = "Lose, opponent has Blackjack \U0001f631"
|
||||
MESSAGE_LOSE_BUST: str = "You went over. You lose \U0001f62d"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""!
|
||||
@file display.py
|
||||
@brief Turns cards, scores and outcomes into text for the console.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from blackjack.constants import (
|
||||
BLACKJACK_MARKER,
|
||||
CARD_RANKS,
|
||||
CARD_SUITS,
|
||||
LABEL_BLACKJACK,
|
||||
MESSAGE_DRAW,
|
||||
MESSAGE_LOSE,
|
||||
MESSAGE_LOSE_BUST,
|
||||
MESSAGE_LOSE_DEALER_BLACKJACK,
|
||||
MESSAGE_WIN,
|
||||
MESSAGE_WIN_BLACKJACK,
|
||||
MESSAGE_WIN_DEALER_BUST,
|
||||
)
|
||||
from blackjack.rules import Outcome
|
||||
|
||||
_OUTCOME_MESSAGES: dict[Outcome, str] = {
|
||||
Outcome.DRAW: MESSAGE_DRAW,
|
||||
Outcome.WIN: MESSAGE_WIN,
|
||||
Outcome.WIN_BLACKJACK: MESSAGE_WIN_BLACKJACK,
|
||||
Outcome.WIN_DEALER_BUST: MESSAGE_WIN_DEALER_BUST,
|
||||
Outcome.LOSE: MESSAGE_LOSE,
|
||||
Outcome.LOSE_DEALER_BLACKJACK: MESSAGE_LOSE_DEALER_BLACKJACK,
|
||||
Outcome.LOSE_BUST: MESSAGE_LOSE_BUST,
|
||||
}
|
||||
|
||||
|
||||
def render_hand(cards: Sequence[int]) -> str:
|
||||
"""!
|
||||
@brief Show a hand as emoji cards.
|
||||
@param cards The card values in the hand.
|
||||
@return The emoji faces separated by spaces.
|
||||
"""
|
||||
return " ".join(
|
||||
f"{CARD_RANKS[card]}{CARD_SUITS[index % len(CARD_SUITS)]}"
|
||||
for index, card in enumerate(cards)
|
||||
)
|
||||
|
||||
|
||||
def format_score(score: int) -> str:
|
||||
"""!
|
||||
@brief Show a score for the player.
|
||||
@param score A score from calculate_score (0 means blackjack).
|
||||
@return The number, or the blackjack label.
|
||||
"""
|
||||
return LABEL_BLACKJACK if score == BLACKJACK_MARKER else str(score)
|
||||
|
||||
|
||||
def outcome_message(outcome: Outcome) -> str:
|
||||
"""!
|
||||
@brief Message announcing how the game ended.
|
||||
@param outcome The outcome seen from the player.
|
||||
@return The text to show.
|
||||
"""
|
||||
return _OUTCOME_MESSAGES[outcome]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""!
|
||||
@file game.py
|
||||
@brief Console game flow: one game, the restart loop and console clearing.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
from blackjack.art import LOGO
|
||||
from blackjack.constants import (
|
||||
ANSWER_NO,
|
||||
ANSWER_YES,
|
||||
BLACKJACK_MARKER,
|
||||
BLACKJACK_SCORE,
|
||||
DEALER_STAND_SCORE,
|
||||
MESSAGE_GOODBYE,
|
||||
MESSAGE_INVALID_ANSWER,
|
||||
OPENING_HAND_SIZE,
|
||||
PROMPT_HIT,
|
||||
PROMPT_RESTART,
|
||||
)
|
||||
from blackjack.display import format_score, outcome_message, render_hand
|
||||
from blackjack.rules import Outcome, calculate_score, compare, deal_card
|
||||
|
||||
## Reads one answer from the player.
|
||||
type Reader = Callable[[str], str]
|
||||
## Shows one line to the player.
|
||||
type Writer = Callable[[str], None]
|
||||
## Draws one card.
|
||||
type Draw = Callable[[], int]
|
||||
|
||||
|
||||
def clear_console() -> None:
|
||||
"""!
|
||||
@brief Clear the terminal screen.
|
||||
"""
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
def ask_yes_no(prompt: str, read: Reader, write: Writer) -> bool:
|
||||
"""!
|
||||
@brief Ask until the player answers yes or no.
|
||||
@param prompt The question to show.
|
||||
@param read Reads the player's answer.
|
||||
@param write Shows a message to the player.
|
||||
@return True for yes, False for no.
|
||||
"""
|
||||
while True:
|
||||
answer = read(prompt).strip().lower()
|
||||
if answer == ANSWER_YES:
|
||||
return True
|
||||
if answer == ANSWER_NO:
|
||||
return False
|
||||
write(MESSAGE_INVALID_ANSWER)
|
||||
|
||||
|
||||
def play_game(read: Reader, write: Writer, draw: Draw) -> Outcome:
|
||||
"""!
|
||||
@brief Play one game: the player draws, then the dealer, then compare.
|
||||
@param read Reads the player's answers.
|
||||
@param write Shows text to the player.
|
||||
@param draw Draws one card.
|
||||
@return The outcome seen from the player.
|
||||
"""
|
||||
user_cards = [draw() for _ in range(OPENING_HAND_SIZE)]
|
||||
computer_cards = [draw() for _ in range(OPENING_HAND_SIZE)]
|
||||
computer_score = 0
|
||||
user_score = 0
|
||||
is_game_over = False
|
||||
while not is_game_over:
|
||||
user_score = calculate_score(user_cards)
|
||||
computer_score = calculate_score(computer_cards)
|
||||
write(
|
||||
f" Your cards: {render_hand(user_cards)}, "
|
||||
f"current score: {format_score(user_score)}"
|
||||
)
|
||||
write(f" Dealer's first card: {render_hand(computer_cards[:1])}")
|
||||
if (
|
||||
user_score == BLACKJACK_MARKER
|
||||
or computer_score == BLACKJACK_MARKER
|
||||
or user_score > BLACKJACK_SCORE
|
||||
):
|
||||
is_game_over = True
|
||||
elif ask_yes_no(PROMPT_HIT, read, write):
|
||||
user_cards.append(draw())
|
||||
else:
|
||||
is_game_over = True
|
||||
|
||||
is_user_bust = user_score > BLACKJACK_SCORE
|
||||
while (
|
||||
not is_user_bust
|
||||
and computer_score != BLACKJACK_MARKER
|
||||
and computer_score < DEALER_STAND_SCORE
|
||||
):
|
||||
computer_cards.append(draw())
|
||||
computer_score = calculate_score(computer_cards)
|
||||
|
||||
write(
|
||||
f" Your final hand: {render_hand(user_cards)}, "
|
||||
f"final score: {format_score(user_score)}"
|
||||
)
|
||||
write(
|
||||
f" Dealer's final hand: {render_hand(computer_cards)}, "
|
||||
f"final score: {format_score(computer_score)}"
|
||||
)
|
||||
outcome = compare(user_score, computer_score)
|
||||
write(outcome_message(outcome))
|
||||
return outcome
|
||||
|
||||
|
||||
def play(
|
||||
read: Reader = input,
|
||||
write: Writer = print,
|
||||
draw: Draw = deal_card,
|
||||
clear: Callable[[], None] = clear_console,
|
||||
) -> None:
|
||||
"""!
|
||||
@brief Play games until the player does not want another one.
|
||||
@param read Reads the player's answers.
|
||||
@param write Shows text to the player.
|
||||
@param draw Draws one card.
|
||||
@param clear Clears the screen before each game.
|
||||
"""
|
||||
while ask_yes_no(PROMPT_RESTART, read, write):
|
||||
clear()
|
||||
write(LOGO)
|
||||
play_game(read, write, draw)
|
||||
write(MESSAGE_GOODBYE)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""!
|
||||
@file rules.py
|
||||
@brief Blackjack house rules as pure functions; no console input or output.
|
||||
"""
|
||||
|
||||
import random
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
|
||||
from blackjack.constants import (
|
||||
ACE_HIGH,
|
||||
ACE_LOW,
|
||||
BLACKJACK_MARKER,
|
||||
BLACKJACK_SCORE,
|
||||
CARDS,
|
||||
OPENING_HAND_SIZE,
|
||||
)
|
||||
|
||||
|
||||
class Outcome(Enum):
|
||||
"""!
|
||||
@brief How a game ended, seen from the player.
|
||||
"""
|
||||
|
||||
DRAW = "draw"
|
||||
WIN = "win"
|
||||
WIN_BLACKJACK = "win_blackjack"
|
||||
WIN_DEALER_BUST = "win_dealer_bust"
|
||||
LOSE = "lose"
|
||||
LOSE_DEALER_BLACKJACK = "lose_dealer_blackjack"
|
||||
LOSE_BUST = "lose_bust"
|
||||
|
||||
|
||||
def deal_card() -> int:
|
||||
"""!
|
||||
@brief Draw one card from the unlimited deck.
|
||||
@return A card value; 11 is the ace and 10 covers 10, Jack, Queen and King.
|
||||
"""
|
||||
return random.choice(CARDS)
|
||||
|
||||
|
||||
def calculate_score(cards: Sequence[int]) -> int:
|
||||
"""!
|
||||
@brief Score a hand.
|
||||
@param cards The card values in the hand.
|
||||
@return The total, or 0 for a blackjack (two cards: an ace and a 10).
|
||||
An ace counts 11 unless that would bust the hand, then it counts 1.
|
||||
"""
|
||||
hand = list(cards)
|
||||
total = sum(hand)
|
||||
if len(hand) == OPENING_HAND_SIZE and total == BLACKJACK_SCORE:
|
||||
return BLACKJACK_MARKER
|
||||
while total > BLACKJACK_SCORE and ACE_HIGH in hand:
|
||||
hand[hand.index(ACE_HIGH)] = ACE_LOW
|
||||
total = sum(hand)
|
||||
return total
|
||||
|
||||
|
||||
def compare(user_score: int, computer_score: int) -> Outcome:
|
||||
"""!
|
||||
@brief Decide who won.
|
||||
@param user_score The player's score (0 means blackjack).
|
||||
@param computer_score The dealer's score (0 means blackjack).
|
||||
@return The outcome seen from the player.
|
||||
"""
|
||||
if user_score == computer_score:
|
||||
return Outcome.DRAW
|
||||
if computer_score == BLACKJACK_MARKER:
|
||||
return Outcome.LOSE_DEALER_BLACKJACK
|
||||
if user_score == BLACKJACK_MARKER:
|
||||
return Outcome.WIN_BLACKJACK
|
||||
if user_score > BLACKJACK_SCORE:
|
||||
return Outcome.LOSE_BUST
|
||||
if computer_score > BLACKJACK_SCORE:
|
||||
return Outcome.WIN_DEALER_BUST
|
||||
if user_score > computer_score:
|
||||
return Outcome.WIN
|
||||
return Outcome.LOSE
|
||||
Reference in New Issue
Block a user