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 class Card: suits = ['♠', '♥', '♦', '♣'] ranks = list(range(1, 11)) + ['J', 'Q', 'K'] def __init__(self, rank, suit): self.rank = rank self.suit = suit @property def value(self): if isinstance(self.rank, int): return self.rank return 10 if self.rank in ['J', 'Q', 'K'] else 1 # Ace low def __repr__(self): return f"{self.rank}{self.suit}" 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): self.cards.remove(card) def get_all_melds(self): return self.find_sets() + self.find_runs() 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) for c in self.cards: suits[c.suit].append(c) for suit, suited_cards in suits.items(): 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 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): 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): self.hand.remove(card) discard_pile.discard(card) self.seen_discards.append(card) 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] # 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 Game: def __init__(self, players): self.deck = Deck() self.discard_pile = DiscardPile() self.players = players self.scores = {p.name: 0 for p in players} def deal(self): for _ in range(10): for player in self.players: player.hand.add(self.deck.draw()) self.discard_pile.discard(self.deck.draw()) def play_round(self): self.deal() turn = 0 while True: player = self.players[turn % 2] print(f"\n{player.name}'s turn") self.take_turn(player) if player.hand.deadwood_points() == 0: print(f"{player.name} goes GIN!") self.score_round(player, gin=True) break elif player.hand.deadwood_points() <= 10: print(f"{player.name} knocks!") self.score_round(player, gin=False) break turn += 1 def take_turn(self, player): # Simple AI: always draw from stock drawn = player.draw(self.deck, self.discard_pile, from_discard=False) print(f"{player.name} draws {drawn}") # Discard highest deadwood card deadwood = player.hand.deadwood() if deadwood: discard = max(deadwood, key=lambda c: c.value) player.discard(self.discard_pile, discard) print(f"{player.name} discards {discard}") def score_round(self, knocker, gin=False): opponent = [p for p in self.players if p != knocker][0] knocker_deadwood = knocker.hand.deadwood_points() opponent_deadwood = opponent.hand.deadwood_points() if gin: score = opponent_deadwood + 25 elif opponent_deadwood <= knocker_deadwood: score = (knocker_deadwood - opponent_deadwood) + 25 # undercut self.scores[opponent.name] += score print(f"{opponent.name} undercuts! Scores {score}") return else: score = opponent_deadwood - knocker_deadwood self.scores[knocker.name] += score print(f"{knocker.name} scores {score}") print("\nScores:") for name, pts in self.scores.items(): print(f"{name}: {pts}") # 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 suit, 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__": players = [Player("Alice"), Player("Bob")] game = Game(players) game.play_round() # Further game logic would go here