232 lines
8.0 KiB
Python
232 lines
8.0 KiB
Python
# hearts.py
|
|
|
|
from collections import Counter
|
|
import random
|
|
import sys
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
from typing import overload
|
|
|
|
|
|
class Card:
|
|
SUITS = "♠ ♡ ♢ ♣".split()
|
|
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
|
|
|
|
def __init__(self, suit: str, rank: str) -> None:
|
|
self.suit = suit
|
|
self.rank = rank
|
|
|
|
@property
|
|
def value(self) -> int:
|
|
"""The value of a card is rank as a number"""
|
|
return self.RANKS.index(self.rank)
|
|
|
|
@property
|
|
def points(self) -> int:
|
|
"""Points this card is worth"""
|
|
if self.suit == "♠" and self.rank == "Q":
|
|
return 13
|
|
if self.suit == "♡":
|
|
return 1
|
|
return 0
|
|
|
|
def __eq__(self, other: Any) -> Any:
|
|
return self.suit == other.suit and self.rank == other.rank
|
|
|
|
def __lt__(self, other: Any) -> Any:
|
|
return self.value < other.value
|
|
|
|
def __repr__(self) -> str:
|
|
return f"{self.suit}{self.rank}"
|
|
|
|
|
|
class Deck(Sequence[Card]):
|
|
def __init__(self, cards: List[Card]) -> None:
|
|
self.cards = cards
|
|
|
|
@classmethod
|
|
def create(cls, shuffle: bool = False) -> "Deck":
|
|
"""Create a new deck of 52 cards"""
|
|
cards = [Card(s, r) for r in Card.RANKS for s in Card.SUITS]
|
|
if shuffle:
|
|
random.shuffle(cards)
|
|
return cls(cards)
|
|
|
|
def play(self, card: Card) -> None:
|
|
"""Play one card by removing it from the deck"""
|
|
self.cards.remove(card)
|
|
|
|
def deal(self, num_hands: int) -> Tuple["Deck", ...]:
|
|
"""Deal the cards in the deck into a number of hands"""
|
|
return tuple(self[i::num_hands] for i in range(num_hands))
|
|
|
|
def add_cards(self, cards: List[Card]) -> None:
|
|
"""Add a list of cards to the deck"""
|
|
self.cards += cards
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.cards)
|
|
|
|
@overload
|
|
def __getitem__(self, key: int) -> Card:
|
|
...
|
|
|
|
@overload
|
|
def __getitem__(self, key: slice) -> "Deck": # noqa
|
|
...
|
|
|
|
def __getitem__( # noqa
|
|
self, key: Union[int, slice]
|
|
) -> Union[Card, "Deck"]:
|
|
if isinstance(key, int):
|
|
return self.cards[key]
|
|
elif isinstance(key, slice):
|
|
cls = self.__class__
|
|
return cls(self.cards[key])
|
|
else:
|
|
raise TypeError("Indices must be integers or slices")
|
|
|
|
def __repr__(self) -> str:
|
|
return " ".join(repr(c) for c in self.cards)
|
|
|
|
|
|
class Player:
|
|
def __init__(self, name: str, hand: Optional[Deck] = None) -> None:
|
|
self.name = name
|
|
self.hand = Deck([]) if hand is None else hand
|
|
|
|
def playable_cards(self, played: List[Card], hearts_broken: bool) -> Deck:
|
|
"""List which cards in hand are playable this round"""
|
|
if Card("♣", "2") in self.hand:
|
|
return Deck([Card("♣", "2")])
|
|
|
|
lead = played[0].suit if played else None
|
|
playable = Deck([c for c in self.hand if c.suit == lead]) or self.hand
|
|
if lead is None and not hearts_broken:
|
|
playable = Deck([c for c in playable if c.suit != "♡"])
|
|
return playable or Deck(self.hand.cards)
|
|
|
|
def non_winning_cards(self, played: List[Card], playable: Deck) -> Deck:
|
|
"""List playable cards that are guaranteed to not win the trick"""
|
|
if not played:
|
|
return Deck([])
|
|
|
|
lead = played[0].suit
|
|
best_card = max(c for c in played if c.suit == lead)
|
|
return Deck([c for c in playable if c < best_card or c.suit != lead])
|
|
|
|
def play_card(self, played: List[Card], hearts_broken: bool) -> Card:
|
|
"""Play a card from a cpu player's hand"""
|
|
playable = self.playable_cards(played, hearts_broken)
|
|
non_winning = self.non_winning_cards(played, playable)
|
|
|
|
# Strategy
|
|
if non_winning:
|
|
# Highest card not winning the trick, prefer points
|
|
card = max(non_winning, key=lambda c: (c.points, c.value))
|
|
elif len(played) < 3:
|
|
# Lowest card maybe winning, avoid points
|
|
card = min(playable, key=lambda c: (c.points, c.value))
|
|
else:
|
|
# Highest card guaranteed winning, avoid points
|
|
card = max(playable, key=lambda c: (-c.points, c.value))
|
|
self.hand.cards.remove(card)
|
|
print(f"{self.name} -> {card}")
|
|
return card
|
|
|
|
def has_card(self, card: Card) -> bool:
|
|
return card in self.hand
|
|
|
|
def __repr__(self) -> str:
|
|
return f"{self.__class__.__name__}({self.name!r}, {self.hand})"
|
|
|
|
|
|
class HumanPlayer(Player):
|
|
def play_card(self, played: List[Card], hearts_broken: bool) -> Card:
|
|
"""Play a card from a human player's hand"""
|
|
playable = sorted(self.playable_cards(played, hearts_broken))
|
|
p_str = " ".join(f"{n}: {c}" for n, c in enumerate(playable))
|
|
np_str = " ".join(repr(c) for c in self.hand if c not in playable)
|
|
print(f" {p_str} (Rest: {np_str})")
|
|
while True:
|
|
try:
|
|
card_num = int(input(f" {self.name}, choose card: "))
|
|
card = playable[card_num]
|
|
except (ValueError, IndexError):
|
|
pass
|
|
else:
|
|
break
|
|
self.hand.play(card)
|
|
print(f"{self.name} => {card}")
|
|
return card
|
|
|
|
|
|
class HeartsGame:
|
|
def __init__(self, *names: str) -> None:
|
|
self.names = (list(names) + "P1 P2 P3 P4".split())[:4]
|
|
self.players = [Player(n) for n in self.names[1:]]
|
|
self.players.append(HumanPlayer(self.names[0]))
|
|
|
|
def play(self) -> None:
|
|
"""Play a game of Hearts until one player go bust"""
|
|
score = Counter({n: 0 for n in self.names})
|
|
while all(s < 100 for s in score.values()):
|
|
print("\nStarting new round:")
|
|
round_score = self.play_round()
|
|
score.update(Counter(round_score))
|
|
print("Scores:")
|
|
for name, total_score in score.most_common(4):
|
|
print(f"{name:<15} {round_score[name]:>3} {total_score:>3}")
|
|
|
|
winners = [n for n in self.names if score[n] == min(score.values())]
|
|
print(f"\n{' and '.join(winners)} won the game")
|
|
|
|
def play_round(self) -> Dict[str, int]:
|
|
"""Play a round of the Hearts card game"""
|
|
deck = Deck.create(shuffle=True)
|
|
for player, hand in zip(self.players, deck.deal(4)):
|
|
player.hand.add_cards(hand.cards)
|
|
start_player = next(
|
|
p for p in self.players if p.has_card(Card("♣", "2"))
|
|
)
|
|
tricks = {p.name: Deck([]) for p in self.players}
|
|
hearts = False
|
|
|
|
# Play cards from each player's hand until empty
|
|
while start_player.hand:
|
|
played: List[Card] = []
|
|
turn_order = self.player_order(start=start_player)
|
|
for player in turn_order:
|
|
card = player.play_card(played, hearts_broken=hearts)
|
|
played.append(card)
|
|
start_player = self.trick_winner(played, turn_order)
|
|
tricks[start_player.name].add_cards(played)
|
|
print(f"{start_player.name} wins the trick\n")
|
|
hearts = hearts or any(c.suit == "♡" for c in played)
|
|
return self.count_points(tricks)
|
|
|
|
def player_order(self, start: Optional[Player] = None) -> List[Player]:
|
|
"""Rotate player order so that start goes first"""
|
|
if start is None:
|
|
start = random.choice(self.players)
|
|
start_idx = self.players.index(start)
|
|
return self.players[start_idx:] + self.players[:start_idx]
|
|
|
|
@staticmethod
|
|
def trick_winner(trick: List[Card], players: List[Player]) -> Player:
|
|
lead = trick[0].suit
|
|
valid = [
|
|
(c.value, p) for c, p in zip(trick, players) if c.suit == lead
|
|
]
|
|
return max(valid)[1]
|
|
|
|
@staticmethod
|
|
def count_points(tricks: Dict[str, Deck]) -> Dict[str, int]:
|
|
return {n: sum(c.points for c in cards) for n, cards in tricks.items()}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Read player names from the command line
|
|
player_names = sys.argv[1:]
|
|
game = HeartsGame(*player_names)
|
|
game.play()
|