Projects/Gin_Rummy_Card_Game.py

461 lines
15 KiB
Python

import random
from collections import defaultdict
from itertools import combinations
import time
# Define card ranks and their order for runs
RANK_ORDER = {str(n): n for n in range(2, 11)}
RANK_ORDER.update({"A": 1, "J": 11, "Q": 12, "K": 13})
# Card, Deck, DiscardPile, Hand, Player, Game classes
VALID_RANKS = [str(n) for n in range(2, 11)] + ["J", "Q", "K", "A"]
class Card:
suits = ['', '', '', '']
ranks = VALID_RANKS
def __init__(self, rank, suit):
rank = str(rank)
if rank not in VALID_RANKS:
raise ValueError(f"Invalid rank: {rank}")
if suit not in self.suits:
raise ValueError(f"Invalid suit: {suit}")
self.rank = rank
self.suit = suit
@staticmethod
def safe_parse_card(card_str):
if not isinstance(card_str, str) or len(card_str) < 2:
return None
rank = card_str[:-1]
suit = card_str[-1]
if rank not in VALID_RANKS or suit not in Card.suits:
return None
return Card(rank, suit)
def __eq__(self, other):
return isinstance(other, Card) and self.rank == other.rank and self.suit == other.suit
def __hash__(self):
return hash((self.rank, self.suit))
def __repr__(self):
return f"{self.rank}{self.suit}"
@property
def value(self):
if self.rank in ['J', 'Q', 'K']:
return 10
elif self.rank == 'A':
return 1
else:
return int(self.rank) # works for '2' to '10'
class Deck:
def __init__(self):
self.cards = [Card(rank, suit) for suit in Card.suits for rank in Card.ranks]
random.seed(time.time()) # Seed random number generator with current time
random.shuffle(self.cards)
def draw(self):
return self.cards.pop() if self.cards else None
class DiscardPile:
def __init__(self):
self.cards = []
def top(self):
return self.cards[-1] if self.cards else None
def discard(self, card):
self.cards.append(card)
class Hand:
def __init__(self):
self.cards = []
def add(self, card):
self.cards.append(card)
def remove(self, card):
for c in self.cards:
if c == card:
self.cards.remove(c)
return
raise ValueError(f"Card {card} not found in hand.")
def get_all_melds(self):
return self.find_sets() + self.find_runs()
def find_card(self, card_str):
rank = card_str[:-1]
suit = card_str[-1]
for card in self.hand.cards:
if card.rank == rank and card.suit == suit:
return card
raise ValueError(f"Card {card_str} not found in hand.")
def find_sets(self):
groups = defaultdict(list)
for c in self.cards:
groups[c.rank].append(c)
return [group for group in groups.values() if len(group) >= 3]
def find_runs(self):
runs = []
suits = defaultdict(list)
# Group cards by suit
for c in self.cards:
if not isinstance(c, Card):
raise TypeError(f"Expected Card object, got {type(c)}: {c}")
suits[c.suit].append(c)
# For each suit, find sequential runs
for suited_cards in suits.values():
sorted_cards = sorted(suited_cards, key=lambda c: RANK_ORDER[str(c.rank)])
temp = [sorted_cards[0]]
for i in range(1, len(sorted_cards)):
prev = RANK_ORDER[str(sorted_cards[i - 1].rank)]
curr = RANK_ORDER[str(sorted_cards[i].rank)]
if curr == prev + 1:
temp.append(sorted_cards[i])
else:
if len(temp) >= 3:
runs.append(temp)
temp = [sorted_cards[i]]
if len(temp) >= 3:
runs.append(temp)
return runs
def validate_hand(self):
for c in self.cards:
if not isinstance(c, Card):
raise TypeError(f"Invalid card in hand: {c} (type {type(c)})")
def non_overlapping_meld_combos(self, all_melds):
valid_combos = []
for r in range(1, len(all_melds) + 1):
for combo in combinations(all_melds, r):
used = set()
overlap = False
for meld in combo:
for card in meld:
if card in used:
overlap = True
break
used.add(card)
if overlap:
break
if not overlap:
valid_combos.append(combo)
return valid_combos
def best_meld_combo(self):
self.validate_hand()
all_melds = self.get_all_melds()
combos = self.non_overlapping_meld_combos(all_melds)
best = max(combos, key=lambda combo: len(set(c for meld in combo for c in meld)), default=[])
return best
def deadwood(self):
"""Returns list of cards not used in best meld combo."""
used = set(c for meld in self.best_meld_combo() for c in meld)
return [c for c in self.cards if c not in used]
def deadwood_points(self):
return sum(c.value for c in self.deadwood())
def __repr__(self):
return f"Hand({self.cards})"
class Player:
def __init__(self, name):
self.name = name
self.hand = Hand()
self.seen_discards = []
def draw(self, deck, discard_pile, from_discard=False):
card = discard_pile.top() if from_discard else deck.draw()
if card:
self.hand.add(card)
if from_discard:
discard_pile.cards.pop()
return card
def discard(self, discard_pile, card):
card = Card.safe_parse_card(card)
print("Hand contains:", self.cards)
print("Trying to remove:", card)
self.hand.remove(card)
discard_pile.discard(card)
self.seen_discards.append(card)
def choose_discard(self):
hand = self.hand.cards
best_melds = self.hand.best_meld_combo()
used = set(c for meld in best_melds for c in meld)
candidates = [c for c in hand if c not in used]
# Avoid discarding cards that match recent discards (opponent may want them back)
risky_ranks = {c.rank for c in self.seen_discards[-5:]} # last few discards
risky_suits = {c.suit for c in self.seen_discards[-5:]}
def score(card):
value_penalty = card.value
suit_cluster = sum(1 for c in hand if c.suit == card.suit)
rank_cluster = sum(1 for c in hand if abs(RANK_ORDER[str(c.rank)] - RANK_ORDER[str(card.rank)]) <= 2)
risk_penalty = 5 if card.rank in risky_ranks or card.suit in risky_suits else 0
return value_penalty + risk_penalty - (suit_cluster + rank_cluster)
# Rank candidates by value and meld potential
if candidates:
return min(candidates, key=score)
else:
return max(hand, key=lambda c: c.value)
class HumanPlayer(Player):
def show_hand(self):
self.hand.validate_hand()
print(f"\n🧍 {self.name}'s Hand: {', '.join(str(c) for c in self.hand.cards)}")
print(f"Melds: {[', '.join(str(c) for c in meld) for meld in self.hand.best_meld_combo()]}")
print(f"Deadwood: {', '.join(str(c) for c in self.hand.deadwood())} ({self.hand.deadwood_points()} pts)")
class AIPlayer(Player):
def show_melds_only(self):
print(f"\n🤖 {self.name}'s Melds:")
for meld in self.hand.best_meld_combo():
print(" ", ', '.join(str(c) for c in meld))
print("Hand: [hidden]")
def choose_discard(self, discard_pile):
hand = self.hand.cards
best_melds = self.hand.best_meld_combo()
used = set(c for meld in best_melds for c in meld)
candidates = [c for c in hand if c not in used]
top = discard_pile.top()
if top:
# Avoid discarding same rank or suit as top card
candidates = [c for c in candidates if c.rank != top.rank and c.suit != top.suit]
# Score each candidate based on risk and meld potential
def score(card, hand):
value_penalty = card.value
suit_cluster = sum(1 for c in hand if c.suit == card.suit)
rank_cluster = sum(1 for c in hand if abs(RANK_ORDER[str(c.rank)] - RANK_ORDER[str(card.rank)]) <= 2)
return value_penalty - (suit_cluster + rank_cluster)
class TextUI:
def __init__(self, game):
self.game = game
def show_player_state(self, player):
if isinstance(player, HumanPlayer):
print(f"\n🧍 {player.name}'s Hand: {', '.join(str(c) for c in player.hand.cards)}")
print(f"Melds: {[', '.join(str(c) for c in meld) for meld in player.hand.best_meld_combo()]}")
print(f"Deadwood: {', '.join(str(c) for c in player.hand.deadwood())} ({player.hand.deadwood_points()} pts)")
elif isinstance(player, AIPlayer):
print(f"\n🤖 {player.name}'s Melds:")
for meld in player.hand.best_meld_combo():
print(" ", ', '.join(str(c) for c in meld))
print("Hand: [hidden]")
def show_turn_banner(self, player_name):
print("\n" + "-" * 30)
print(f"🎯 {player_name}'s Turn")
print("-" * 30)
def show_game_over(self, winner, score):
print("\n" + "=" * 40)
print("🎉 GAME OVER 🎉")
print(f"{winner} wins with {score} points!")
print("=" * 40)
print("\nWould you like to play again? (Y/N)")
choice = input("> ").strip().upper()
if choice == "Y":
self.reset_game()
else:
print("Thanks for playing Gin Rummy!")
def show_draw(self, player_name, card):
print(f"{player_name} draws: {card}")
def show_discard(self, player_name, card):
print(f"{player_name} discards: {card}")
class Game:
def __init__(self, human_player, ai_player):
self.human = human_player
self.ai = ai_player
self.players = [self.human, self.ai]
self.deck = Deck()
self.discard_pile = DiscardPile()
self.scores = {p.name: 0 for p in self.players}
self.ui = TextUI(self)
def deal(self):
for _ in range(10):
for player in self.players:
self.human.hand.validate_hand()
self.ai.hand.validate_hand()
player.hand.add(self.deck.draw())
self.discard_pile.discard(self.deck.draw())
def play_round(self, player):
self.deal()
turn = 0
while True:
current = self.players[turn % 2]
self.ui.show_player_state(player)
self.ui.show_turn_banner(current.name)
if isinstance(current, HumanPlayer):
self.human_turn(current)
else:
self.ai_turn(current)
if current.hand.deadwood_points() == 0:
print(f"{current.name} goes GIN!")
self.score_round(current, gin=True)
break
elif current.hand.deadwood_points() <= 10:
print(f"{current.name} knocks!")
self.score_round(current, gin=False)
break
turn += 1
if self.check_game_over():
return
def human_turn(self, player):
choice = self.ui.prompt_draw_choice()
from_discard = choice == "D"
drawn = player.draw(self.deck, self.discard_pile, from_discard)
self.ui.show_draw(player.name, drawn)
discard_input = input("Choose a card to discard (e.g., '5♠'): ").strip()
try:
discard_card = player.hand.find_card(discard_input)
player.discard(self.discard_pile, discard_card)
self.ui.show_discard(player.name, discard_card)
except ValueError as e:
print(e)
self.human_turn(player) # retry
def ai_turn(self, player):
# AI always draws from stock for now
drawn = player.draw(self.deck, self.discard_pile, from_discard=False)
self.ui.show_draw(player.name, drawn)
discard = player.choose_discard(self.discard_pile)
player.discard(self.discard_pile, discard)
self.ui.show_discard(player.name, discard)
def score_round(knocker, opponent, gin=False):
knocker_deadwood = knocker.hand.deadwood_points()
opponent_deadwood = opponent.hand.deadwood_points()
if gin:
score = opponent_deadwood + 25
return {knocker.name: score, opponent.name: 0}, "gin"
if opponent_deadwood <= knocker_deadwood:
score = (knocker_deadwood - opponent_deadwood) + 25
return {knocker.name: 0, opponent.name: score}, "undercut"
score = opponent_deadwood - knocker_deadwood
return {knocker.name: score, opponent.name: 0}, "knock"
def check_game_over(self, winning_score=100):
for name, score in self.scores.items():
if score >= winning_score:
self.ui.show_game_over(winner=name, score=score)
return True
return False
def reset_game(self):
self.deck = Deck()
self.discard_pile = DiscardPile()
for p in self.players:
p.hand = Hand()
p.seen_discards = []
self.scores = {p.name: 0 for p in self.players}
self.play_round()
# Functions to detect melds outside classes
# Function to detect sets
def find_sets(cards):
groups = defaultdict(list)
for c in cards:
groups[c.rank].append(c)
return [group for group in groups.values() if len(group) >= 3]
# Function to detect runs
def find_runs(cards):
runs = []
# group by suit
suits = defaultdict(list)
for c in cards:
suits[c.suit].append(c)
for suited_cards in suits.items():
# sort by rank order
sorted_cards = sorted(suited_cards, key=lambda c: RANK_ORDER[str(c.rank)])
# scan for consecutive sequences
temp = [sorted_cards[0]]
for i in range(1, len(sorted_cards)):
prev = RANK_ORDER[str(sorted_cards[i-1].rank)]
curr = RANK_ORDER[str(sorted_cards[i].rank)]
if curr == prev + 1:
temp.append(sorted_cards[i])
else:
if len(temp) >= 3:
runs.append(temp)
temp = [sorted_cards[i]]
if len(temp) >= 3:
runs.append(temp)
return runs
def non_overlapping_meld_combos(all_melds):
"""
Given a list of all possible melds (sets and runs),
return all combinations where no card is used more than once.
"""
valid_combos = []
for r in range(1, len(all_melds) + 1):
for combo in combinations(all_melds, r):
used = set()
overlap = False
for meld in combo:
for card in meld:
if card in used:
overlap = True
break
used.add(card)
if overlap:
break
if not overlap:
valid_combos.append(combo)
return valid_combos
# start a game with two players
if __name__ == "__main__":
human = HumanPlayer("Python")
ai = AIPlayer("Bot")
game = Game(ai, human)
player = human # Human starts first
game.play_round(player)
# Further game logic would go here