""" Game logic for 6-Card Golf. This module implements the core game mechanics for the 6-Card Golf card game, including card/deck management, player state, scoring rules, and game flow. 6-Card Golf Rules Summary: - Each player has 6 cards arranged in a 2x3 grid (2 rows, 3 columns) - Goal: Achieve the lowest score over multiple rounds (holes) - On your turn: Draw from deck or discard pile, then swap or discard - Matching pairs in a column cancel out (score 0) - Game ends when one player reveals all cards, then others get one final turn Card Layout: [0] [1] [2] <- top row [3] [4] [5] <- bottom row Columns: (0,3), (1,4), (2,5) - matching ranks in a column score 0 """ import random import uuid from collections import Counter from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Optional, Callable, Any from constants import ( DEFAULT_CARD_VALUES, SUPER_KINGS_VALUE, TEN_PENNY_VALUE, LUCKY_SWING_JOKER_VALUE, WOLFPACK_BONUS, FOUR_OF_A_KIND_BONUS, ) class FlipMode(str, Enum): """ Mode for flip-on-discard rule. NEVER: No flip when discarding from deck (standard rules) ALWAYS: Must flip when discarding from deck (Speed Golf - faster games) ENDGAME: Optional flip when any player has ≤1 face-down card (Suspense mode) """ NEVER = "never" ALWAYS = "always" ENDGAME = "endgame" class Suit(Enum): """Card suits for a standard deck.""" HEARTS = "hearts" DIAMONDS = "diamonds" CLUBS = "clubs" SPADES = "spades" class Rank(Enum): """ Card ranks with their display values. Standard Golf scoring (can be modified by house rules): - Ace: 1 point - 2-10: Face value (except Kings) - Jack/Queen: 10 points - King: 0 points - Joker: -2 points (when enabled) """ ACE = "A" TWO = "2" THREE = "3" FOUR = "4" FIVE = "5" SIX = "6" SEVEN = "7" EIGHT = "8" NINE = "9" TEN = "10" JACK = "J" QUEEN = "Q" KING = "K" JOKER = "★" # Map Rank enum to point values (derived from constants.py as single source of truth) RANK_VALUES: dict[Rank, int] = {rank: DEFAULT_CARD_VALUES[rank.value] for rank in Rank} def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int: """ Get point value for a card, with house rules applied. This is the single source of truth for Card object value calculations. Use this instead of card.value() when house rules need to be considered. Args: card: Card object to evaluate options: Optional GameOptions with house rule flags Returns: Point value for the card """ if options: if card.rank == Rank.JOKER: if options.eagle_eye: return 2 # Eagle-eyed: jokers worth +2 unpaired, -4 when paired (handled in calculate_score) return LUCKY_SWING_JOKER_VALUE if options.lucky_swing else RANK_VALUES[Rank.JOKER] if card.rank == Rank.KING and options.super_kings: return SUPER_KINGS_VALUE if card.rank == Rank.TEN and options.ten_penny: return TEN_PENNY_VALUE # One-eyed Jacks: J♥ and J♠ are worth 0 instead of 10 if options.one_eyed_jacks: if card.rank == Rank.JACK and card.suit in (Suit.HEARTS, Suit.SPADES): return 0 return RANK_VALUES[card.rank] @dataclass class Card: """ A playing card with suit, rank, and face-up state. Attributes: suit: The card's suit (hearts, diamonds, clubs, spades). rank: The card's rank (A, 2-10, J, Q, K, or Joker). face_up: Whether the card is visible to all players. deck_id: Which deck this card came from (0-indexed, for multi-deck games). """ suit: Suit rank: Rank face_up: bool = False deck_id: int = 0 def to_dict(self, reveal: bool = False) -> dict: """ Convert card to dictionary for JSON serialization. Always includes full card data (rank/suit/face_up) for server-side caching and event sourcing. Use to_client_dict() for client views that should hide face-down cards. Args: reveal: Ignored for backwards compatibility. Use to_client_dict() instead. Returns: Dict with full card info (suit, rank, face_up). """ return { "suit": self.suit.value, "rank": self.rank.value, "face_up": self.face_up, "deck_id": self.deck_id, } def to_client_dict(self) -> dict: """ Convert card to dictionary for client display. Hides card details if face-down to prevent cheating, but always includes deck_id so the client can show the correct back color. Returns: Dict with card info, or just {face_up: False, deck_id} if hidden. """ if self.face_up: return { "suit": self.suit.value, "rank": self.rank.value, "face_up": True, "deck_id": self.deck_id, } return {"face_up": False, "deck_id": self.deck_id} def value(self) -> int: """Get base point value (without house rule modifications).""" return RANK_VALUES[self.rank] class Deck: """ A deck of playing cards that can be shuffled and drawn from. Supports multiple standard 52-card decks combined, with optional jokers in various configurations (standard 2-per-deck or lucky swing). For event sourcing, the deck can be initialized with a seed for deterministic shuffling, enabling exact game replay. """ def __init__( self, num_decks: int = 1, use_jokers: bool = False, lucky_swing: bool = False, seed: Optional[int] = None, ) -> None: """ Initialize a new deck. Args: num_decks: Number of standard 52-card decks to combine. use_jokers: Whether to include joker cards. lucky_swing: If True, use single -5 joker instead of two -2 jokers. seed: Optional random seed for deterministic shuffle. If None, a random seed is generated and stored. """ self.cards: list[Card] = [] self.seed: int = seed if seed is not None else random.randint(0, 2**31 - 1) # Build deck(s) with standard cards for deck_idx in range(num_decks): for suit in Suit: for rank in Rank: if rank != Rank.JOKER: self.cards.append(Card(suit, rank, deck_id=deck_idx)) # Standard jokers: 2 per deck, worth -2 each if use_jokers and not lucky_swing: self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=deck_idx)) self.cards.append(Card(Suit.SPADES, Rank.JOKER, deck_id=deck_idx)) # Lucky Swing: Single joker total, worth -5 if use_jokers and lucky_swing: self.cards.append(Card(Suit.HEARTS, Rank.JOKER, deck_id=0)) self.shuffle() def shuffle(self, seed: Optional[int] = None) -> None: """ Randomize the order of cards in the deck. Args: seed: Optional seed to use. If None, uses the deck's stored seed. """ if seed is not None: self.seed = seed random.seed(self.seed) random.shuffle(self.cards) # Reset random state to not affect other random calls random.seed() def draw(self) -> Optional[Card]: """ Draw the top card from the deck. Returns: The drawn Card, or None if deck is empty. """ if self.cards: return self.cards.pop() return None def cards_remaining(self) -> int: """Return the number of cards left in the deck.""" return len(self.cards) def top_card_deck_id(self) -> Optional[int]: """Return the deck_id of the top card (for showing correct back color).""" if self.cards: return self.cards[-1].deck_id return None def add_cards(self, cards: list[Card]) -> None: """ Add cards to the deck and shuffle. Used when reshuffling the discard pile back into the deck. Args: cards: List of cards to add. """ self.cards.extend(cards) self.shuffle() @dataclass class Player: """ A player in the Golf card game. Attributes: id: Unique identifier for the player. name: Display name. cards: The player's 6-card hand arranged in a 2x3 grid. score: Points scored in the current round. total_score: Cumulative points across all rounds. rounds_won: Number of rounds where this player had the lowest score. """ id: str name: str cards: list[Card] = field(default_factory=list) score: int = 0 total_score: int = 0 rounds_won: int = 0 def all_face_up(self) -> bool: """Check if all of the player's cards are revealed.""" return all(card.face_up for card in self.cards) def flip_card(self, position: int) -> None: """ Reveal a card at the given position. Args: position: Index 0-5 in the card grid. """ if 0 <= position < len(self.cards): self.cards[position].face_up = True def swap_card(self, position: int, new_card: Card) -> Card: """ Replace a card in the player's hand with a new card. Args: position: Index 0-5 in the card grid. new_card: The card to place in the hand. Returns: The card that was replaced (now face-up for discard). """ old_card = self.cards[position] new_card.face_up = True self.cards[position] = new_card return old_card def calculate_score(self, options: Optional["GameOptions"] = None) -> int: """ Calculate the player's score for the current round. Scoring rules: - Each card contributes its point value - Matching pairs in a column (same rank) cancel out (score 0) - House rules may modify individual card values or add bonuses Card grid layout: [0] [1] [2] <- top row [3] [4] [5] <- bottom row Columns: (0,3), (1,4), (2,5) Args: options: Game options with house rule flags. Returns: Total score for this round (lower is better). """ if len(self.cards) != 6: return 0 total = 0 jack_pairs = 0 # Track paired Jacks for Wolfpack bonus paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind # Evaluation order matters here. We check special-case pairs BEFORE the # default "pairs cancel to 0" rule, because house rules can override that: # 1. Eagle Eye joker pairs -> -4 (better than 0, exit early) # 2. Negative pairs keep value -> sum of negatives (worse than 0, exit early) # 3. Normal pairs -> 0 (skip both cards) # 4. Non-matching -> sum both values # Bonuses (wolfpack, four-of-a-kind) are applied after all columns are scored. for col in range(3): top_idx = col bottom_idx = col + 3 top_card = self.cards[top_idx] bottom_card = self.cards[bottom_idx] # Check if column pair matches (same rank cancels out) if top_card.rank == bottom_card.rank: paired_ranks.append(top_card.rank) # Track Jack pairs for Wolfpack bonus if top_card.rank == Rank.JACK: jack_pairs += 1 # Eagle Eye: paired jokers score -4 instead of 0 if (options and options.eagle_eye and top_card.rank == Rank.JOKER): total -= 4 continue # Negative Pairs Keep Value: paired 2s/Jokers keep their negative value if options and options.negative_pairs_keep_value: top_val = get_card_value(top_card, options) bottom_val = get_card_value(bottom_card, options) if top_val < 0 or bottom_val < 0: # Keep negative value instead of 0 total += top_val + bottom_val continue # Normal matching pair: scores 0 (skip adding values) continue # Non-matching cards: add both values total += get_card_value(top_card, options) total += get_card_value(bottom_card, options) # Wolfpack bonus: 2+ pairs of Jacks if options and options.wolfpack and jack_pairs >= 2: total += WOLFPACK_BONUS # -20 # Four of a Kind bonus: same rank appears twice in paired_ranks # (meaning 4 cards of that rank across 2 columns) if options and options.four_of_a_kind: rank_counts = Counter(paired_ranks) for rank, count in rank_counts.items(): if count >= 2: # Four of a kind! Apply bonus total += FOUR_OF_A_KIND_BONUS # -20 self.score = total return total def cards_to_dict(self, reveal: bool = False) -> list[dict]: """ Convert all cards to dictionaries for JSON serialization. Args: reveal: If True, show all card details regardless of face-up state. Returns: List of card dictionaries. """ if reveal: return [card.to_dict() for card in self.cards] return [card.to_client_dict() for card in self.cards] class GamePhase(Enum): """ Phases of a Golf game round. Flow: WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER After all rounds: GAME_OVER """ WAITING = "waiting" # Lobby, waiting for players to join INITIAL_FLIP = "initial_flip" # Players choosing initial cards to reveal PLAYING = "playing" # Normal gameplay, taking turns FINAL_TURN = "final_turn" # After someone reveals all cards, others get one turn ROUND_OVER = "round_over" # Round complete, showing scores GAME_OVER = "game_over" # All rounds complete, showing final standings @dataclass class GameOptions: """ Configuration options for game rules and house variants. These options can modify scoring, add special rules, and change gameplay. All options default to False/standard values for a classic Golf game. """ # --- Standard Options --- flip_mode: str = "never" """Flip mode when discarding from deck: 'never', 'always', or 'endgame'.""" initial_flips: int = 2 """Number of cards each player reveals at round start (0, 1, or 2).""" knock_penalty: bool = False """If True, +10 penalty if you go out but don't have the lowest score.""" use_jokers: bool = False """If True, add joker cards to the deck.""" # --- House Rules: Point Modifiers --- lucky_swing: bool = False """Use single -5 joker instead of two -2 jokers.""" super_kings: bool = False """Kings worth -2 instead of 0.""" ten_penny: bool = False """10s worth 1 point instead of 10.""" # --- House Rules: Bonuses/Penalties --- knock_bonus: bool = False """First player to reveal all cards gets -5 bonus.""" underdog_bonus: bool = False """Lowest scorer each round gets -3 bonus.""" tied_shame: bool = False """Players who tie with another get +5 penalty.""" blackjack: bool = False """Hole score of exactly 21 becomes 0.""" wolfpack: bool = False """Two pairs of Jacks (all 4 Jacks) grants -5 bonus.""" # --- House Rules: Special --- eagle_eye: bool = False """Jokers worth +2 unpaired, -4 when paired (instead of -2/0).""" # --- House Rules: New Variants (all OFF by default for classic gameplay) --- flip_as_action: bool = False """Allow using turn to flip a face-down card without drawing.""" four_of_a_kind: bool = False """Four equal cards in two columns scores -20 points bonus.""" negative_pairs_keep_value: bool = False """Paired 2s and Jokers keep their negative value (-4) instead of becoming 0.""" one_eyed_jacks: bool = False """One-eyed Jacks (J♥ and J♠) are worth 0 points instead of 10.""" knock_early: bool = False """Allow going out early by flipping all remaining cards (max 2 face-down).""" deck_colors: list[str] = field(default_factory=lambda: ["red", "blue", "gold"]) """Colors for card backs from different decks (in order by deck_id).""" def is_standard_rules(self) -> bool: """Check if all rules are standard (no house rules active).""" return not any([ self.flip_mode != "never", self.initial_flips != 2, self.knock_penalty, self.use_jokers, self.lucky_swing, self.super_kings, self.ten_penny, self.knock_bonus, self.underdog_bonus, self.tied_shame, self.blackjack, self.wolfpack, self.eagle_eye, self.flip_as_action, self.four_of_a_kind, self.negative_pairs_keep_value, self.one_eyed_jacks, self.knock_early, ]) _ALLOWED_COLORS = { "red", "blue", "gold", "teal", "purple", "orange", "yellow", "green", "pink", "cyan", "brown", "slate", } @classmethod def from_client_data(cls, data: dict) -> "GameOptions": """Build GameOptions from client WebSocket message data.""" raw_deck_colors = data.get("deck_colors", ["red", "blue", "gold"]) deck_colors = [c for c in raw_deck_colors if c in cls._ALLOWED_COLORS] if not deck_colors: deck_colors = ["red", "blue", "gold"] return cls( flip_mode=data.get("flip_mode", "never"), initial_flips=max(0, min(2, data.get("initial_flips", 2))), knock_penalty=data.get("knock_penalty", False), use_jokers=data.get("use_jokers", False), lucky_swing=data.get("lucky_swing", False), super_kings=data.get("super_kings", False), ten_penny=data.get("ten_penny", False), knock_bonus=data.get("knock_bonus", False), underdog_bonus=data.get("underdog_bonus", False), tied_shame=data.get("tied_shame", False), blackjack=data.get("blackjack", False), eagle_eye=data.get("eagle_eye", False), wolfpack=data.get("wolfpack", False), flip_as_action=data.get("flip_as_action", False), four_of_a_kind=data.get("four_of_a_kind", False), negative_pairs_keep_value=data.get("negative_pairs_keep_value", False), one_eyed_jacks=data.get("one_eyed_jacks", False), knock_early=data.get("knock_early", False), deck_colors=deck_colors, ) @dataclass class Game: """ Main game state and logic controller for 6-Card Golf. Manages the full game lifecycle including: - Player management (add/remove/lookup) - Deck and discard pile management - Turn flow and phase transitions - Scoring with house rules - Multi-round game progression Attributes: players: List of players in the game (max 6). deck: The draw pile. discard_pile: Face-up cards that have been discarded. current_player_index: Index of the player whose turn it is. phase: Current game phase (waiting, playing, etc.). num_decks: Number of 52-card decks combined. num_rounds: Total rounds (holes) to play. current_round: Current round number (1-indexed). drawn_card: Card currently held by active player (if any). drawn_from_discard: Whether drawn_card came from discard pile. finisher_id: ID of player who first revealed all cards. players_with_final_turn: Set of player IDs who've had final turn. initial_flips_done: Set of player IDs who've done initial flips. options: Game configuration and house rules. game_id: Unique identifier for event sourcing. """ players: list[Player] = field(default_factory=list) deck: Optional[Deck] = None discard_pile: list[Card] = field(default_factory=list) current_player_index: int = 0 phase: GamePhase = GamePhase.WAITING num_decks: int = 1 num_rounds: int = 1 current_round: int = 1 drawn_card: Optional[Card] = None drawn_from_discard: bool = False finisher_id: Optional[str] = None players_with_final_turn: set = field(default_factory=set) initial_flips_done: set = field(default_factory=set) options: GameOptions = field(default_factory=GameOptions) dealer_idx: int = 0 # Event sourcing support game_id: str = field(default_factory=lambda: str(uuid.uuid4())) _event_emitter: Optional[Callable[["GameEvent"], None]] = field( default=None, repr=False, compare=False ) _sequence_num: int = field(default=0, repr=False, compare=False) def set_event_emitter(self, emitter: Callable[["GameEvent"], None]) -> None: """ Set callback for event emission. The emitter will be called with each GameEvent as it occurs. This enables event sourcing without changing game logic. Args: emitter: Callback function that receives GameEvent objects. """ self._event_emitter = emitter def emit_game_created(self, room_code: str, host_id: str) -> None: """ Emit the game_created event. Should be called after setting up the event emitter and before any players join. This establishes the game in the event store. Args: room_code: 4-letter room code. host_id: ID of the player who created the room. """ self._emit( "game_created", player_id=host_id, room_code=room_code, host_id=host_id, options={}, # Options not set until game starts ) def _emit( self, event_type: str, player_id: Optional[str] = None, **data: Any, ) -> None: """ Emit an event if emitter is configured. Args: event_type: Event type string (from EventType enum). player_id: ID of player who triggered the event. **data: Event-specific data fields. """ if self._event_emitter is None: return # Import here to avoid circular dependency from models.events import GameEvent, EventType self._sequence_num += 1 event = GameEvent( event_type=EventType(event_type), game_id=self.game_id, sequence_num=self._sequence_num, player_id=player_id, data=data, ) self._event_emitter(event) @property def flip_on_discard(self) -> bool: """ Whether current turn requires/allows a flip after discard. Returns True if: - flip_mode is 'always' (Speed Golf) - flip_mode is 'endgame' AND any player has ≤1 face-down card (Suspense) """ if self.options.flip_mode == FlipMode.ALWAYS.value: return True if self.options.flip_mode == FlipMode.ENDGAME.value: # Check if any player has ≤1 face-down card for player in self.players: face_down_count = sum(1 for c in player.cards if not c.face_up) if face_down_count <= 1: return True return False return False # "never" @property def flip_is_optional(self) -> bool: """ Whether the flip is optional (endgame mode) vs mandatory (always mode). In endgame mode, player can choose to skip the flip. """ return self.options.flip_mode == FlipMode.ENDGAME.value and self.flip_on_discard def get_card_values(self) -> dict[str, int]: """ Get card value mapping with house rules applied. Returns: Dict mapping rank strings to point values. """ values = DEFAULT_CARD_VALUES.copy() if self.options.super_kings: values['K'] = SUPER_KINGS_VALUE if self.options.ten_penny: values['10'] = TEN_PENNY_VALUE if self.options.lucky_swing: values['★'] = LUCKY_SWING_JOKER_VALUE elif self.options.eagle_eye: values['★'] = 2 # +2 unpaired, -4 paired (handled in scoring) return values # ------------------------------------------------------------------------- # Player Management # ------------------------------------------------------------------------- def add_player( self, player: Player, is_cpu: bool = False, cpu_profile: Optional[str] = None, ) -> bool: """ Add a player to the game. Args: player: The player to add. is_cpu: Whether this is a CPU player. cpu_profile: CPU profile name (for AI replay analysis). Returns: True if added, False if game is full (max 6 players). """ if len(self.players) >= 6: return False self.players.append(player) # Emit player_joined event self._emit( "player_joined", player_id=player.id, player_name=player.name, is_cpu=is_cpu, cpu_profile=cpu_profile, ) return True def remove_player(self, player_id: str, reason: str = "left") -> Optional[Player]: """ Remove a player from the game by ID. Args: player_id: The unique ID of the player to remove. reason: Why the player left (left, disconnected, kicked). Returns: The removed Player, or None if not found. """ for i, player in enumerate(self.players): if player.id == player_id: removed = self.players.pop(i) # Adjust dealer_idx if needed after removal if self.players and self.dealer_idx >= len(self.players): self.dealer_idx = 0 self._emit("player_left", player_id=player_id, reason=reason) return removed return None def get_player(self, player_id: str) -> Optional[Player]: """ Find a player by their ID. Args: player_id: The unique ID to search for. Returns: The Player if found, None otherwise. """ for player in self.players: if player.id == player_id: return player return None def current_player(self) -> Optional[Player]: """Get the player whose turn it currently is.""" if self.players: return self.players[self.current_player_index] return None # ------------------------------------------------------------------------- # Game Lifecycle # ------------------------------------------------------------------------- def start_game( self, num_decks: int = 1, num_rounds: int = 1, options: Optional[GameOptions] = None, ) -> None: """ Initialize and start a new game. Args: num_decks: Number of card decks to use (1-3). num_rounds: Number of rounds/holes to play. options: Game configuration and house rules. """ self.num_decks = num_decks self.num_rounds = num_rounds self.options = options or GameOptions() self.current_round = 1 # Emit game_started event self._emit( "game_started", player_order=[p.id for p in self.players], num_decks=num_decks, num_rounds=num_rounds, options=self._options_to_dict(), ) self.start_round() def _options_to_dict(self) -> dict: """Convert GameOptions to dictionary for event storage.""" return asdict(self.options) # Boolean rules that map directly to display names _RULE_DISPLAY = [ ("knock_penalty", "Knock Penalty"), ("lucky_swing", "Lucky Swing"), ("eagle_eye", "Eagle-Eye"), ("super_kings", "Super Kings"), ("ten_penny", "Ten Penny"), ("knock_bonus", "Knock Bonus"), ("underdog_bonus", "Underdog"), ("tied_shame", "Tied Shame"), ("blackjack", "Blackjack"), ("wolfpack", "Wolfpack"), ("flip_as_action", "Flip as Action"), ("four_of_a_kind", "Four of a Kind"), ("negative_pairs_keep_value", "Negative Pairs Keep Value"), ("one_eyed_jacks", "One-Eyed Jacks"), ("knock_early", "Early Knock"), ] def _get_active_rules(self) -> list[str]: """Build list of active house rule display names.""" rules = [] if not self.options: return rules # Special: flip mode if self.options.flip_mode == FlipMode.ALWAYS.value: rules.append("Speed Golf") elif self.options.flip_mode == FlipMode.ENDGAME.value: rules.append("Endgame Flip") # Special: jokers (only if not overridden by lucky_swing/eagle_eye) if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye: rules.append("Jokers") # Boolean rules for attr, display_name in self._RULE_DISPLAY: if getattr(self.options, attr): rules.append(display_name) return rules def start_round(self) -> None: """ Initialize a new round. Creates fresh deck, deals 6 cards to each player, starts discard pile, and sets phase to INITIAL_FLIP (or PLAYING if no flips required). """ self.deck = Deck( self.num_decks, use_jokers=self.options.use_jokers, lucky_swing=self.options.lucky_swing, ) self.discard_pile = [] self.drawn_card = None self.drawn_from_discard = False self.finisher_id = None self.players_with_final_turn = set() self.initial_flips_done = set() # Deal 6 cards to each player dealt_cards: dict[str, list[dict]] = {} for player in self.players: player.cards = [] player.score = 0 for _ in range(6): card = self.deck.draw() if card: player.cards.append(card) # Store dealt cards for event (include hidden card values server-side) dealt_cards[player.id] = [ {"rank": c.rank.value, "suit": c.suit.value} for c in player.cards ] # Start discard pile with one face-up card first_discard = self.deck.draw() first_discard_dict = None if first_discard: first_discard.face_up = True self.discard_pile.append(first_discard) first_discard_dict = { "rank": first_discard.rank.value, "suit": first_discard.suit.value, } # Rotate dealer clockwise each round (first round: host deals) if self.current_round > 1: self.dealer_idx = (self.dealer_idx + 1) % len(self.players) # "Left of dealer goes first" — standard card game convention. # In our circular list, "left" is the next index. self.current_player_index = (self.dealer_idx + 1) % len(self.players) # Emit round_started event with deck seed and all dealt cards self._emit( "round_started", round_num=self.current_round, deck_seed=self.deck.seed, dealt_cards=dealt_cards, first_discard=first_discard_dict, current_player_idx=self.current_player_index, ) # Skip initial flip phase if 0 flips required if self.options.initial_flips == 0: self.phase = GamePhase.PLAYING else: self.phase = GamePhase.INITIAL_FLIP def flip_initial_cards(self, player_id: str, positions: list[int]) -> bool: """ Handle a player's initial card flip selection. Called during INITIAL_FLIP phase when player chooses which cards to reveal at the start of the round. Args: player_id: ID of the player flipping cards. positions: List of card positions (0-5) to flip. Returns: True if flips were valid and applied, False otherwise. """ if self.phase != GamePhase.INITIAL_FLIP: return False if player_id in self.initial_flips_done: return False required_flips = self.options.initial_flips if len(positions) != required_flips: return False player = self.get_player(player_id) if not player: return False for pos in positions: if not (0 <= pos < 6): return False player.flip_card(pos) self.initial_flips_done.add(player_id) # Emit initial_flip event with revealed cards flipped_cards = [ {"rank": player.cards[pos].rank.value, "suit": player.cards[pos].suit.value} for pos in positions ] self._emit( "initial_flip", player_id=player_id, positions=positions, cards=flipped_cards, ) # Transition to PLAYING when all players have flipped if len(self.initial_flips_done) == len(self.players): self.phase = GamePhase.PLAYING return True # ------------------------------------------------------------------------- # Turn Actions # ------------------------------------------------------------------------- def draw_card(self, player_id: str, source: str) -> Optional[Card]: """ Draw a card from the deck or discard pile. This is the first action of a player's turn. After drawing, the player must either swap the card with one in their hand or discard it (if drawn from deck). Args: player_id: ID of the player drawing. source: Either "deck" or "discard". Returns: The drawn Card, or None if action is invalid. """ player = self.current_player() if not player or player.id != player_id: return None if self.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): return None if self.drawn_card is not None: return None if source == "deck": card = self.deck.draw() if not card: # Deck empty - try to reshuffle discard pile card = self._reshuffle_discard_pile() if card: self.drawn_card = card self.drawn_from_discard = False # Emit card_drawn event (with actual card value, server-side only) self._emit( "card_drawn", player_id=player_id, source=source, card={"rank": card.rank.value, "suit": card.suit.value}, ) return card # No cards available anywhere - end round gracefully self._end_round() return None if source == "discard" and self.discard_pile: card = self.discard_pile.pop() self.drawn_card = card self.drawn_from_discard = True # Emit card_drawn event self._emit( "card_drawn", player_id=player_id, source=source, card={"rank": card.rank.value, "suit": card.suit.value}, ) return card return None def _reshuffle_discard_pile(self) -> Optional[Card]: """ Reshuffle the discard pile back into the deck. Called when the deck is empty. Keeps the top discard card visible, shuffles the rest back into the deck, and draws a card. Returns: A drawn Card from the reshuffled deck, or None if not possible. """ if len(self.discard_pile) <= 1: return None # Keep the top card visible, reshuffle the rest top_card = self.discard_pile[-1] cards_to_reshuffle = self.discard_pile[:-1] for card in cards_to_reshuffle: card.face_up = False self.deck.add_cards(cards_to_reshuffle) self.discard_pile = [top_card] return self.deck.draw() def swap_card(self, player_id: str, position: int) -> Optional[Card]: """ Swap the drawn card with a card in the player's hand. The swapped-out card goes to the discard pile face-up. Args: player_id: ID of the player swapping. position: Index 0-5 in the player's card grid. Returns: The card that was replaced, or None if action is invalid. """ player = self.current_player() if not player or player.id != player_id: return None if self.drawn_card is None: return None if not (0 <= position < 6): return None new_card = self.drawn_card old_card = player.swap_card(position, self.drawn_card) old_card.face_up = True self.discard_pile.append(old_card) self.drawn_card = None # Emit card_swapped event self._emit( "card_swapped", player_id=player_id, position=position, new_card={"rank": new_card.rank.value, "suit": new_card.suit.value}, old_card={"rank": old_card.rank.value, "suit": old_card.suit.value}, ) self._check_end_turn(player) return old_card def can_discard_drawn(self) -> bool: """ Check if the current player can discard their drawn card. Cards drawn from the discard pile must be swapped (cannot be discarded). Returns: True if discard is allowed, False if swap is required. """ if self.drawn_from_discard: return False return True def cancel_discard_draw(self, player_id: str) -> bool: """ Cancel a draw from the discard pile, putting the card back. Only allowed when the card was drawn from the discard pile. This is a convenience feature to undo accidental clicks. Args: player_id: ID of the player canceling. Returns: True if cancel was successful, False otherwise. """ player = self.current_player() if not player or player.id != player_id: return False if self.drawn_card is None: return False if not self.drawn_from_discard: return False # Can only cancel discard draws # Put the card back on the discard pile cancelled_card = self.drawn_card cancelled_card.face_up = True self.discard_pile.append(cancelled_card) self.drawn_card = None self.drawn_from_discard = False # Emit cancel event self._emit( "draw_cancelled", player_id=player_id, card={"rank": cancelled_card.rank.value, "suit": cancelled_card.suit.value}, ) return True def discard_drawn(self, player_id: str) -> bool: """ Discard the drawn card without swapping. Only allowed when the card was drawn from the deck (not discard pile). If flip_on_discard is enabled, player must then flip a face-down card. Args: player_id: ID of the player discarding. Returns: True if discard was successful, False otherwise. """ player = self.current_player() if not player or player.id != player_id: return False if self.drawn_card is None: return False if not self.can_discard_drawn(): return False discarded_card = self.drawn_card self.drawn_card.face_up = True self.discard_pile.append(self.drawn_card) self.drawn_card = None # Emit card_discarded event self._emit( "card_discarded", player_id=player_id, card={"rank": discarded_card.rank.value, "suit": discarded_card.suit.value}, ) if self.flip_on_discard: # Player must flip a card before turn ends has_face_down = any(not card.face_up for card in player.cards) if not has_face_down: self._check_end_turn(player) # Otherwise, wait for flip_and_end_turn to be called else: self._check_end_turn(player) return True def flip_and_end_turn(self, player_id: str, position: int) -> bool: """ Flip a face-down card to complete turn (flip_on_discard variant). Called after discarding when flip_on_discard option is enabled. Args: player_id: ID of the player flipping. position: Index 0-5 of the card to flip. Returns: True if flip was valid and turn ended, False otherwise. """ player = self.current_player() if not player or player.id != player_id: return False if not (0 <= position < 6): return False if player.cards[position].face_up: return False player.flip_card(position) flipped_card = player.cards[position] # Emit card_flipped event self._emit( "card_flipped", player_id=player_id, position=position, card={"rank": flipped_card.rank.value, "suit": flipped_card.suit.value}, ) self._check_end_turn(player) return True def skip_flip_and_end_turn(self, player_id: str) -> bool: """ Skip optional flip and end turn (endgame mode only). In endgame mode (flip_mode='endgame'), the flip is optional, so players can choose to skip it and end their turn immediately. Args: player_id: ID of the player skipping the flip. Returns: True if skip was valid and turn ended, False otherwise. """ if not self.flip_is_optional: return False player = self.current_player() if not player or player.id != player_id: return False # Emit flip_skipped event self._emit("flip_skipped", player_id=player_id) self._check_end_turn(player) return True def flip_card_as_action(self, player_id: str, card_index: int) -> bool: """ Use turn to flip a face-down card without drawing. Only valid if flip_as_action house rule is enabled. This is an alternative to drawing - player flips one of their face-down cards to see what it is, then their turn ends. Args: player_id: ID of the player using this action. card_index: Index 0-5 of the card to flip. Returns: True if action was valid and turn ended, False otherwise. """ if not self.options.flip_as_action: return False player = self.current_player() if not player or player.id != player_id: return False if self.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): return False # Can't use this action if already drawn a card if self.drawn_card is not None: return False if not (0 <= card_index < len(player.cards)): return False if player.cards[card_index].face_up: return False # Already face-up, can't flip player.cards[card_index].face_up = True flipped_card = player.cards[card_index] # Emit flip_as_action event self._emit( "flip_as_action", player_id=player_id, position=card_index, card={"rank": flipped_card.rank.value, "suit": flipped_card.suit.value}, ) self._check_end_turn(player) return True def knock_early(self, player_id: str) -> bool: """ Flip all remaining face-down cards at once to go out early. Only valid if knock_early house rule is enabled and player has at most 2 face-down cards remaining. This is a gamble - you're betting your hidden cards are good enough to win. Args: player_id: ID of the player knocking early. Returns: True if action was valid, False otherwise. """ if not self.options.knock_early: return False player = self.current_player() if not player or player.id != player_id: return False if self.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): return False # Can't use this action if already drawn a card if self.drawn_card is not None: return False # Count face-down cards face_down_indices = [i for i, c in enumerate(player.cards) if not c.face_up] # Must have at least 1 and at most 2 face-down cards if len(face_down_indices) == 0 or len(face_down_indices) > 2: return False # Flip all remaining face-down cards revealed_cards = [] for idx in face_down_indices: player.cards[idx].face_up = True revealed_cards.append({ "rank": player.cards[idx].rank.value, "suit": player.cards[idx].suit.value, }) # Emit knock_early event self._emit( "knock_early", player_id=player_id, positions=face_down_indices, cards=revealed_cards, ) self._check_end_turn(player) return True # ------------------------------------------------------------------------- # Turn & Round Flow (Internal) # ------------------------------------------------------------------------- def _check_end_turn(self, player: Player) -> None: """ Check if player triggered end-game and advance to next turn. If the player has revealed all cards and is the first to do so, triggers FINAL_TURN phase where other players get one more turn. In FINAL_TURN phase, reveal all of the player's cards after their turn. Args: player: The player whose turn just ended. """ # This method and _next_turn() are tightly coupled. _check_end_turn populates # players_with_final_turn BEFORE calling _next_turn(), which reads it to decide # whether the round is over. Reordering these calls will break end-of-round logic. if player.all_face_up() and self.finisher_id is None: self.finisher_id = player.id self.phase = GamePhase.FINAL_TURN self.players_with_final_turn.add(player.id) elif self.phase == GamePhase.FINAL_TURN: # Reveal this player's cards immediately after their final turn for card in player.cards: card.face_up = True self._next_turn() def _next_turn(self) -> None: """ Advance to the next player's turn. In FINAL_TURN phase, tracks which players have had their final turn and ends the round when everyone has played. Depends on _check_end_turn() having already added the current player to players_with_final_turn. """ if self.phase == GamePhase.FINAL_TURN: next_index = (self.current_player_index + 1) % len(self.players) next_player = self.players[next_index] if next_player.id in self.players_with_final_turn: # Everyone has had their final turn self._end_round() return self.current_player_index = next_index self.players_with_final_turn.add(next_player.id) else: self.current_player_index = (self.current_player_index + 1) % len(self.players) # ------------------------------------------------------------------------- # Scoring & Round End # ------------------------------------------------------------------------- def _end_round(self) -> None: """ End the current round and calculate final scores. Reveals all cards, calculates base scores, then applies house rule bonuses and penalties in order: 1. Blackjack (21 -> 0) 2. Knock penalty (+10 if finisher doesn't have lowest) 3. Knock bonus (-5 to finisher) 4. Underdog bonus (-3 to lowest scorer) 5. Tied shame (+5 to players with matching scores) Finally, updates total scores and awards round wins. """ self.phase = GamePhase.ROUND_OVER # Reveal all cards and calculate base scores for player in self.players: for card in player.cards: card.face_up = True player.calculate_score(self.options) # --- Apply House Rule Bonuses/Penalties --- # Order matters. Blackjack converts 21->0 first, so knock penalty checks # against the post-blackjack score. Knock penalty before knock bonus so they # can stack (you get penalized AND rewarded, net +5). Underdog before tied shame # so the -3 bonus can create new ties that then get punished. It's mean by design. # Blackjack: exact score of 21 becomes 0 if self.options.blackjack: for player in self.players: if player.score == 21: player.score = 0 # Knock penalty: +10 if finisher doesn't have the lowest score if self.options.knock_penalty and self.finisher_id: finisher = self.get_player(self.finisher_id) if finisher: min_score = min(p.score for p in self.players) if finisher.score > min_score: finisher.score += 10 # Knock bonus: -5 to the player who went out first if self.options.knock_bonus and self.finisher_id: finisher = self.get_player(self.finisher_id) if finisher: finisher.score -= 5 # Underdog bonus: -3 to the lowest scorer(s) if self.options.underdog_bonus: min_score = min(p.score for p in self.players) for player in self.players: if player.score == min_score: player.score -= 3 # Tied shame: +5 to players who share a score with someone if self.options.tied_shame: score_counts = Counter(p.score for p in self.players) for player in self.players: if score_counts[player.score] > 1: player.score += 5 # Update cumulative totals for player in self.players: player.total_score += player.score # Award round win to lowest scorer(s) min_score = min(p.score for p in self.players) for player in self.players: if player.score == min_score: player.rounds_won += 1 # Emit round_ended event scores = {p.id: p.score for p in self.players} final_hands = { p.id: [{"rank": c.rank.value, "suit": c.suit.value} for c in p.cards] for p in self.players } self._emit( "round_ended", round_num=self.current_round, scores=scores, final_hands=final_hands, finisher_id=self.finisher_id, ) def start_next_round(self) -> bool: """ Start the next round of the game. Returns: True if next round started, False if game is over or not ready. """ if self.phase != GamePhase.ROUND_OVER: return False if self.current_round >= self.num_rounds: self.phase = GamePhase.GAME_OVER # Emit game_ended event final_scores = {p.id: p.total_score for p in self.players} rounds_won = {p.id: p.rounds_won for p in self.players} # Determine winner (lowest total score) winner_id = None if self.players: min_score = min(p.total_score for p in self.players) winners = [p for p in self.players if p.total_score == min_score] if len(winners) == 1: winner_id = winners[0].id self._emit( "game_ended", final_scores=final_scores, rounds_won=rounds_won, winner_id=winner_id, ) return False self.current_round += 1 self.start_round() return True # ------------------------------------------------------------------------- # State Queries # ------------------------------------------------------------------------- def discard_top(self) -> Optional[Card]: """Get the top card of the discard pile (if any).""" if self.discard_pile: return self.discard_pile[-1] return None def get_state(self, for_player_id: str) -> dict: """ Get the full game state for a specific player. Returns a dictionary suitable for JSON serialization and sending to the client. Hides opponent card values unless the round is over. Args: for_player_id: The player who will receive this state. Their own cards are always revealed. Returns: Dict containing phase, players, current turn, discard pile, deck info, round info, and active house rules. """ current = self.current_player() # Card visibility has three cases: # 1. Round/game over: all cards revealed to everyone (reveal=True) # 2. Your own cards: always revealed to you (is_self=True) # 3. Opponent cards mid-game: only face-up cards shown, hidden cards are redacted players_data = [] for player in self.players: reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) is_self = player.id == for_player_id players_data.append({ "id": player.id, "name": player.name, "cards": player.cards_to_dict(reveal=reveal or is_self), "score": player.score if reveal else None, "total_score": player.total_score, "rounds_won": player.rounds_won, "all_face_up": player.all_face_up(), }) discard_top = self.discard_top() active_rules = self._get_active_rules() return { "phase": self.phase.value, "players": players_data, "current_player_id": current.id if current else None, "dealer_id": self.players[self.dealer_idx].id if self.players else None, "dealer_idx": self.dealer_idx, "discard_top": discard_top.to_dict(reveal=True) if discard_top else None, "deck_remaining": self.deck.cards_remaining() if self.deck else 0, "deck_top_deck_id": self.deck.top_card_deck_id() if self.deck else None, "current_round": self.current_round, "total_rounds": self.num_rounds, "has_drawn_card": self.drawn_card is not None, "drawn_card": self.drawn_card.to_dict(reveal=True) if self.drawn_card else None, "drawn_player_id": current.id if current and self.drawn_card else None, "can_discard": self.can_discard_drawn() if self.drawn_card else True, "waiting_for_initial_flip": ( self.phase == GamePhase.INITIAL_FLIP and for_player_id not in self.initial_flips_done ), "initial_flips": self.options.initial_flips, "flip_on_discard": self.flip_on_discard, "flip_mode": self.options.flip_mode, "flip_is_optional": self.flip_is_optional, "flip_as_action": self.options.flip_as_action, "knock_early": self.options.knock_early, "finisher_id": self.finisher_id, "card_values": self.get_card_values(), "active_rules": active_rules, "scoring_rules": { "negative_pairs_keep_value": self.options.negative_pairs_keep_value, "eagle_eye": self.options.eagle_eye, "wolfpack": self.options.wolfpack, "four_of_a_kind": self.options.four_of_a_kind, "one_eyed_jacks": self.options.one_eyed_jacks, }, "deck_colors": self.options.deck_colors, "is_standard_rules": self.options.is_standard_rules(), }