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