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:
2026-10-04 22:04:40 +08:00
parent 3381855019
commit 3c99040b96
10 changed files with 737 additions and 16 deletions
+21
View File
@@ -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()
+16
View File
@@ -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
View File
@@ -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"
+61
View File
@@ -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]
+128
View File
@@ -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)
+78
View File
@@ -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