From d9073f862cb70c2373866f1b69ae9ad0cb2334de Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 25 Jan 2026 00:10:26 -0500 Subject: [PATCH] Add documentation and move rules display to header - Add comprehensive docstrings to game.py, room.py, constants.py - Document all classes, methods, and module-level items - Move active rules display into game header as inline column - Update header to 5-column grid layout - Update joker mode descriptions (Lucky Swing, Eagle-Eye) Co-Authored-By: Claude Opus 4.5 --- client/index.html | 13 +- client/style.css | 30 +-- server/constants.py | 40 ++- server/game.py | 586 +++++++++++++++++++++++++++++++++++++------- server/room.py | 200 +++++++++++++-- 5 files changed, 717 insertions(+), 152 deletions(-) diff --git a/client/index.html b/client/index.html index 4642200..b54b7fd 100644 --- a/client/index.html +++ b/client/index.html @@ -127,12 +127,12 @@ @@ -204,6 +204,10 @@
Hole 1/9
+
Your turn
Showing: 0
@@ -212,11 +216,6 @@
- -
diff --git a/client/style.css b/client/style.css index 626bea5..c0bbb84 100644 --- a/client/style.css +++ b/client/style.css @@ -463,8 +463,9 @@ input::placeholder { /* Game Screen */ .game-header { display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-columns: auto 1fr auto auto auto; align-items: center; + gap: 15px; padding: 10px 25px; background: rgba(0,0,0,0.35); border-radius: 0; @@ -475,22 +476,21 @@ input::placeholder { } .game-header .round-info { - justify-self: start; font-weight: 600; + white-space: nowrap; } .game-header .turn-info { - justify-self: center; font-weight: 600; color: #f4a460; + white-space: nowrap; } .game-header .score-info { - justify-self: center; + white-space: nowrap; } .game-header .header-buttons { - justify-self: end; display: flex; align-items: center; gap: 10px; @@ -517,16 +517,17 @@ input::placeholder { background: rgba(255,255,255,0.1); } -/* Active Rules Bar */ +/* Active Rules (in header) */ .active-rules-bar { display: flex; align-items: center; justify-content: center; - gap: 8px; - padding: 6px 20px; - background: rgba(0, 0, 0, 0.25); - font-size: 0.8rem; - flex-wrap: wrap; + gap: 6px; + font-size: 0.85rem; +} + +.active-rules-bar.hidden { + display: none; } .active-rules-bar .rules-label { @@ -536,18 +537,17 @@ input::placeholder { .active-rules-bar .rules-list { display: flex; - gap: 6px; + gap: 5px; flex-wrap: wrap; - justify-content: center; } .active-rules-bar .rule-tag { - background: rgba(244, 164, 96, 0.25); + background: rgba(244, 164, 96, 0.3); color: #f4a460; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; - font-weight: 500; + font-weight: 600; } /* Card Styles */ diff --git a/server/constants.py b/server/constants.py index 8501b79..df5bf5d 100644 --- a/server/constants.py +++ b/server/constants.py @@ -1,6 +1,22 @@ -# Card values - Single source of truth for all card scoring -# Per RULES.md: A=1, 2=-2, 3-10=face, J/Q=10, K=0, Joker=-2 -DEFAULT_CARD_VALUES = { +""" +Card value constants for 6-Card Golf. + +This module is the single source of truth for all card point values. +House rule modifications are defined here and applied in game.py. + +Standard Golf Scoring: + - Ace: 1 point + - Two: -2 points (special - only negative non-joker) + - 3-9: Face value + - 10, Jack, Queen: 10 points + - King: 0 points + - Joker: -2 points (when enabled) +""" + +from typing import Optional + +# Base card values (no house rules applied) +DEFAULT_CARD_VALUES: dict[str, int] = { 'A': 1, '2': -2, '3': 3, @@ -14,17 +30,19 @@ DEFAULT_CARD_VALUES = { 'J': 10, 'Q': 10, 'K': 0, - '★': -2, # Joker (standard) + '★': -2, # Joker (standard mode) } -# House rule modifications (per RULES.md House Rules section) -SUPER_KINGS_VALUE = -2 # K worth -2 instead of 0 -LUCKY_SEVENS_VALUE = 0 # 7 worth 0 instead of 7 -TEN_PENNY_VALUE = 1 # 10 worth 1 instead of 10 -LUCKY_SWING_JOKER_VALUE = -5 # Joker worth -5 in Lucky Swing mode +# --- House Rule Value Overrides --- +SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0 +TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10 +LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5 -def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int: +def get_card_value_for_rank( + rank_str: str, + options: Optional[dict] = None, +) -> int: """ Get point value for a card rank string, with house rules applied. @@ -45,8 +63,6 @@ def get_card_value_for_rank(rank_str: str, options: dict | None = None) -> int: value = LUCKY_SWING_JOKER_VALUE elif rank_str == 'K' and options.get('super_kings'): value = SUPER_KINGS_VALUE - elif rank_str == '7' and options.get('lucky_sevens'): - value = LUCKY_SEVENS_VALUE elif rank_str == '10' and options.get('ten_penny'): value = TEN_PENNY_VALUE diff --git a/server/game.py b/server/game.py index d5df571..a6b75fb 100644 --- a/server/game.py +++ b/server/game.py @@ -1,9 +1,28 @@ -"""Game logic for 6-Card Golf.""" +""" +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 +from collections import Counter from dataclasses import dataclass, field -from typing import Optional from enum import Enum +from typing import Optional from constants import ( DEFAULT_CARD_VALUES, @@ -14,6 +33,8 @@ from constants import ( class Suit(Enum): + """Card suits for a standard deck.""" + HEARTS = "hearts" DIAMONDS = "diamonds" CLUBS = "clubs" @@ -21,6 +42,17 @@ class Suit(Enum): 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" @@ -37,8 +69,8 @@ class Rank(Enum): JOKER = "★" -# Derive RANK_VALUES from DEFAULT_CARD_VALUES (single source of truth in constants.py) -RANK_VALUES = {rank: DEFAULT_CARD_VALUES[rank.value] for rank in Rank} +# 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: @@ -69,11 +101,29 @@ def get_card_value(card: "Card", options: Optional["GameOptions"] = None) -> int @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. + """ + suit: Suit rank: Rank face_up: bool = False def to_dict(self, reveal: bool = False) -> dict: + """ + Convert card to dictionary for JSON serialization. + + Args: + reveal: If True, show card details even if face-down. + + Returns: + Dict with card info, or just {face_up: False} if hidden. + """ if self.face_up or reveal: return { "suit": self.suit.value, @@ -83,45 +133,98 @@ class Card: return {"face_up": False} def value(self) -> int: + """Get base point value (without house rule modifications).""" return RANK_VALUES[self.rank] class Deck: - def __init__(self, num_decks: int = 1, use_jokers: bool = False, lucky_swing: bool = False): + """ + 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). + """ + + def __init__( + self, + num_decks: int = 1, + use_jokers: bool = False, + lucky_swing: bool = False, + ) -> 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. + """ self.cards: list[Card] = [] + + # Build deck(s) with standard cards for _ in range(num_decks): for suit in Suit: for rank in Rank: if rank != Rank.JOKER: self.cards.append(Card(suit, rank)) + + # Standard jokers: 2 per deck, worth -2 each if use_jokers and not lucky_swing: - # Standard: Add 2 jokers worth -2 each per deck self.cards.append(Card(Suit.HEARTS, Rank.JOKER)) self.cards.append(Card(Suit.SPADES, Rank.JOKER)) - # Lucky Swing: Add just 1 joker total (worth -5) + + # Lucky Swing: Single joker total, worth -5 if use_jokers and lucky_swing: self.cards.append(Card(Suit.HEARTS, Rank.JOKER)) + self.shuffle() - def shuffle(self): + def shuffle(self) -> None: + """Randomize the order of cards in the deck.""" random.shuffle(self.cards) 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 add_cards(self, cards: list[Card]): - """Add cards to the deck and shuffle.""" + 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) @@ -130,54 +233,87 @@ class Player: 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): + 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 score with column pair matching and house rules.""" + """ + 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 - # Cards are arranged in 2 rows x 3 columns - # Position mapping: [0, 1, 2] (top row) - # [3, 4, 5] (bottom row) - # Columns: (0,3), (1,4), (2,5) - 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) + # Check if column pair matches (same rank cancels out) if top_card.rank == bottom_card.rank: - # Track Jack pairs for Wolfpack + # Track Jack pairs for Wolfpack bonus if top_card.rank == Rank.JACK: jack_pairs += 1 - # Eagle Eye: paired jokers score -4 (reward for spotting the pair) + + # Eagle Eye: paired jokers score -4 instead of 0 if (options and options.eagle_eye and - top_card.rank == Rank.JOKER and bottom_card.rank == Rank.JOKER): + top_card.rank == Rank.JOKER): total -= 4 continue - # Normal matching pair scores 0 - continue - else: - total += get_card_value(top_card, options) - total += get_card_value(bottom_card, options) - # Wolfpack bonus: 2 pairs of Jacks = -5 pts + # 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 = -5 pts if options and options.wolfpack and jack_pairs >= 2: total -= 5 @@ -185,44 +321,116 @@ class Player: 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. + """ return [card.to_dict(reveal) for card in self.cards] class GamePhase(Enum): - WAITING = "waiting" - INITIAL_FLIP = "initial_flip" - PLAYING = "playing" - FINAL_TURN = "final_turn" - ROUND_OVER = "round_over" - GAME_OVER = "game_over" + """ + 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: - # Standard options - flip_on_discard: bool = False # Flip a card when discarding from deck - initial_flips: int = 2 # Cards to flip at start (0, 1, or 2) - knock_penalty: bool = False # +10 if you go out but don't have lowest - use_jokers: bool = False # Add jokers worth -2 points + """ + Configuration options for game rules and house variants. - # House Rules - Point Modifiers - lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers - super_kings: bool = False # Kings worth -2 instead of 0 - ten_penny: bool = False # 10s worth 1 (like Ace) instead of 10 + These options can modify scoring, add special rules, and change gameplay. + All options default to False/standard values for a classic Golf game. + """ - # House Rules - Bonuses/Penalties - knock_bonus: bool = False # First to reveal all cards gets -5 bonus - underdog_bonus: bool = False # Lowest score player gets -3 each hole - tied_shame: bool = False # Tie with someone's score = +5 penalty to both - blackjack: bool = False # Hole score of exactly 21 becomes 0 - wolfpack: bool = False # 2 pairs of Jacks = -5 bonus + # --- Standard Options --- + flip_on_discard: bool = False + """If True, player must flip a face-down card after discarding from deck.""" - # House Rules - Special - eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10) + 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).""" @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. + """ + players: list[Player] = field(default_factory=list) deck: Optional[Deck] = None discard_pile: list[Card] = field(default_factory=list) @@ -232,7 +440,7 @@ class Game: num_rounds: int = 1 current_round: int = 1 drawn_card: Optional[Card] = None - drawn_from_discard: bool = False # Track if current draw was from discard + 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) @@ -240,13 +448,18 @@ class Game: @property def flip_on_discard(self) -> bool: + """Convenience property for flip_on_discard option.""" return self.options.flip_on_discard - def get_card_values(self) -> dict: - """Get current card values with house rules applied.""" + 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() - # Apply house rule modifications if self.options.super_kings: values['K'] = SUPER_KINGS_VALUE if self.options.ten_penny: @@ -254,45 +467,100 @@ class Game: if self.options.lucky_swing: values['★'] = LUCKY_SWING_JOKER_VALUE elif self.options.eagle_eye: - values['★'] = 2 # Eagle-eyed: +2 unpaired, -4 paired + values['★'] = 2 # +2 unpaired, -4 paired (handled in scoring) return values + # ------------------------------------------------------------------------- + # Player Management + # ------------------------------------------------------------------------- + def add_player(self, player: Player) -> bool: + """ + Add a player to the game. + + Args: + player: The player to add. + + Returns: + True if added, False if game is full (max 6 players). + """ if len(self.players) >= 6: return False self.players.append(player) return True def remove_player(self, player_id: str) -> Optional[Player]: + """ + Remove a player from the game by ID. + + Args: + player_id: The unique ID of the player to remove. + + Returns: + The removed Player, or None if not found. + """ for i, player in enumerate(self.players): if player.id == player_id: return self.players.pop(i) 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 - def start_game(self, num_decks: int = 1, num_rounds: int = 1, options: Optional[GameOptions] = 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 self.start_round() - def start_round(self): + 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 + lucky_swing=self.options.lucky_swing, ) self.discard_pile = [] self.drawn_card = None @@ -310,13 +578,14 @@ class Game: if card: player.cards.append(card) - # Start discard pile with one card + # Start discard pile with one face-up card first_discard = self.deck.draw() if first_discard: first_discard.face_up = True self.discard_pile.append(first_discard) self.current_player_index = 0 + # Skip initial flip phase if 0 flips required if self.options.initial_flips == 0: self.phase = GamePhase.PLAYING @@ -324,6 +593,19 @@ class Game: 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 @@ -345,13 +627,31 @@ class Game: self.initial_flips_done.add(player_id) - # Check if all players have flipped + # 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 @@ -371,11 +671,11 @@ class Game: self.drawn_card = card self.drawn_from_discard = False return card - else: - # No cards available anywhere - end round gracefully - self._end_round() - return None - elif source == "discard" and self.discard_pile: + # 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 @@ -384,29 +684,43 @@ class Game: return None def _reshuffle_discard_pile(self) -> Optional[Card]: - """Reshuffle discard pile into deck, keeping top card. Returns drawn card or None.""" + """ + 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: - # No cards to reshuffle (only top card or empty) return None - # Keep the top card, take the rest + # Keep the top card visible, reshuffle the rest top_card = self.discard_pile[-1] cards_to_reshuffle = self.discard_pile[:-1] - # Reset face_up for reshuffled cards for card in cards_to_reshuffle: card.face_up = False - # Add to deck and shuffle self.deck.add_cards(cards_to_reshuffle) - - # Keep only top card in discard pile self.discard_pile = [top_card] - # Draw from the newly shuffled deck 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 @@ -426,13 +740,31 @@ class Game: return old_card def can_discard_drawn(self) -> bool: - """Check if player can discard the drawn card.""" - # Must swap if taking from discard pile (always enforced) + """ + 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 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 @@ -440,7 +772,6 @@ class Game: if self.drawn_card is None: return False - # Cannot discard if drawn from discard pile (must swap) if not self.can_discard_drawn(): return False @@ -449,18 +780,29 @@ class Game: self.drawn_card = None if self.flip_on_discard: - # Version 1: Must flip a card after discarding + # 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: - # Version 2 (default): Just end the turn self._check_end_turn(player) + return True def flip_and_end_turn(self, player_id: str, position: int) -> bool: - """Flip a face-down card after discarding from deck draw.""" + """ + 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 @@ -475,19 +817,35 @@ class Game: self._check_end_turn(player) return True - def _check_end_turn(self, player: Player): - # Check if player finished (all cards face up) + # ------------------------------------------------------------------------- + # 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. + + Args: + player: The player whose turn just ended. + """ 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) - # Move to next player self._next_turn() - def _next_turn(self): + 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. + """ if self.phase == GamePhase.FINAL_TURN: - # In final turn phase, track who has had their turn next_index = (self.current_player_index + 1) % len(self.players) next_player = self.players[next_index] @@ -501,22 +859,41 @@ class Game: else: self.current_player_index = (self.current_player_index + 1) % len(self.players) - def _end_round(self): + # ------------------------------------------------------------------------- + # 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 scores + # 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 Blackjack rule: score of exactly 21 becomes 0 + # --- Apply House Rule Bonuses/Penalties --- + + # Blackjack: exact score of 21 becomes 0 if self.options.blackjack: for player in self.players: if player.score == 21: player.score = 0 - # Apply knock penalty if enabled (+10 if you go out but don't have lowest) + # 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: @@ -524,27 +901,27 @@ class Game: if finisher.score > min_score: finisher.score += 10 - # Apply knock bonus if enabled (-5 to first player who reveals all) + # 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 - # Apply underdog bonus (-3 to lowest scorer) + # 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 - # Apply tied shame (+5 to players who tie with someone else) + # Tied shame: +5 to players who share a score with someone if self.options.tied_shame: - from collections import Counter 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 @@ -555,6 +932,12 @@ class Game: player.rounds_won += 1 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 @@ -566,12 +949,31 @@ class Game: 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() players_data = [] diff --git a/server/room.py b/server/room.py index bc6b77c..cbd6320 100644 --- a/server/room.py +++ b/server/room.py @@ -1,17 +1,44 @@ -"""Room management for multiplayer games.""" +""" +Room management for multiplayer Golf games. + +This module handles room creation, player management, and WebSocket +communication for multiplayer game sessions. + +A Room contains: + - A unique 4-letter code for joining + - A collection of RoomPlayers (human or CPU) + - A Game instance with the actual game state + - Settings for number of decks, rounds, etc. +""" import random import string from dataclasses import dataclass, field from typing import Optional + from fastapi import WebSocket +from ai import assign_profile, assign_specific_profile, get_profile, release_profile from game import Game, Player -from ai import assign_profile, release_profile, get_profile, assign_specific_profile @dataclass class RoomPlayer: + """ + A player in a game room (lobby-level representation). + + This is separate from game.Player - RoomPlayer tracks room-level info + like WebSocket connections and host status, while game.Player tracks + in-game state like cards and scores. + + Attributes: + id: Unique player identifier. + name: Display name. + websocket: WebSocket connection (None for CPU players). + is_host: Whether this player controls game settings. + is_cpu: Whether this is an AI-controlled player. + """ + id: str name: str websocket: Optional[WebSocket] = None @@ -21,13 +48,42 @@ class RoomPlayer: @dataclass class Room: + """ + A game room/lobby that can host a multiplayer Golf game. + + Attributes: + code: 4-letter room code for joining (e.g., "ABCD"). + players: Dict mapping player IDs to RoomPlayer objects. + game: The Game instance containing actual game state. + settings: Room settings (decks, rounds, etc.). + game_log_id: SQLite log ID for analytics (if logging enabled). + """ + code: str players: dict[str, RoomPlayer] = field(default_factory=dict) game: Game = field(default_factory=Game) settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1}) - game_log_id: Optional[str] = None # For SQLite logging + game_log_id: Optional[str] = None - def add_player(self, player_id: str, name: str, websocket: WebSocket) -> RoomPlayer: + def add_player( + self, + player_id: str, + name: str, + websocket: WebSocket, + ) -> RoomPlayer: + """ + Add a human player to the room. + + The first player to join becomes the host. + + Args: + player_id: Unique identifier for the player. + name: Display name. + websocket: The player's WebSocket connection. + + Returns: + The created RoomPlayer object. + """ is_host = len(self.players) == 0 room_player = RoomPlayer( id=player_id, @@ -37,21 +93,33 @@ class Room: ) self.players[player_id] = room_player - # Add to game game_player = Player(id=player_id, name=name) self.game.add_player(game_player) return room_player - def add_cpu_player(self, cpu_id: str, profile_name: Optional[str] = None) -> Optional[RoomPlayer]: - # Get a CPU profile (specific or random) + def add_cpu_player( + self, + cpu_id: str, + profile_name: Optional[str] = None, + ) -> Optional[RoomPlayer]: + """ + Add a CPU player to the room. + + Args: + cpu_id: Unique identifier for the CPU player. + profile_name: Specific AI profile to use, or None for random. + + Returns: + The created RoomPlayer, or None if profile unavailable. + """ if profile_name: profile = assign_specific_profile(cpu_id, profile_name) else: profile = assign_profile(cpu_id) if not profile: - return None # Profile not available + return None room_player = RoomPlayer( id=cpu_id, @@ -62,39 +130,64 @@ class Room: ) self.players[cpu_id] = room_player - # Add to game game_player = Player(id=cpu_id, name=profile.name) self.game.add_player(game_player) return room_player def remove_player(self, player_id: str) -> Optional[RoomPlayer]: - if player_id in self.players: - room_player = self.players.pop(player_id) - self.game.remove_player(player_id) + """ + Remove a player from the room. - # Release CPU profile back to the pool - if room_player.is_cpu: - release_profile(room_player.name) + Handles host reassignment if the host leaves, and releases + CPU profiles back to the pool. - # Assign new host if needed - if room_player.is_host and self.players: - next_host = next(iter(self.players.values())) - next_host.is_host = True + Args: + player_id: ID of the player to remove. - return room_player - return None + Returns: + The removed RoomPlayer, or None if not found. + """ + if player_id not in self.players: + return None + + room_player = self.players.pop(player_id) + self.game.remove_player(player_id) + + # Release CPU profile back to the pool + if room_player.is_cpu: + release_profile(room_player.name) + + # Assign new host if needed + if room_player.is_host and self.players: + next_host = next(iter(self.players.values())) + next_host.is_host = True + + return room_player def get_player(self, player_id: str) -> Optional[RoomPlayer]: + """Get a player by ID, or None if not found.""" return self.players.get(player_id) def is_empty(self) -> bool: + """Check if the room has no players.""" return len(self.players) == 0 def player_list(self) -> list[dict]: + """ + Get list of players for client display. + + Returns: + List of dicts with id, name, is_host, is_cpu, and style (for CPUs). + """ result = [] for p in self.players.values(): - player_data = {"id": p.id, "name": p.name, "is_host": p.is_host, "is_cpu": p.is_cpu} + player_data = { + "id": p.id, + "name": p.name, + "is_host": p.is_host, + "is_cpu": p.is_cpu, + } if p.is_cpu: profile = get_profile(p.id) if profile: @@ -103,12 +196,21 @@ class Room: return result def get_cpu_players(self) -> list[RoomPlayer]: + """Get all CPU players in the room.""" return [p for p in self.players.values() if p.is_cpu] def human_player_count(self) -> int: + """Count the number of human (non-CPU) players.""" return sum(1 for p in self.players.values() if not p.is_cpu) - async def broadcast(self, message: dict, exclude: Optional[str] = None): + async def broadcast(self, message: dict, exclude: Optional[str] = None) -> None: + """ + Send a message to all human players in the room. + + Args: + message: JSON-serializable message dict. + exclude: Optional player ID to skip. + """ for player_id, player in self.players.items(): if player_id != exclude and player.websocket and not player.is_cpu: try: @@ -116,7 +218,14 @@ class Room: except Exception: pass - async def send_to(self, player_id: str, message: dict): + async def send_to(self, player_id: str, message: dict) -> None: + """ + Send a message to a specific player. + + Args: + player_id: ID of the recipient player. + message: JSON-serializable message dict. + """ player = self.players.get(player_id) if player and player.websocket and not player.is_cpu: try: @@ -126,29 +235,68 @@ class Room: class RoomManager: - def __init__(self): + """ + Manages all active game rooms. + + Provides room creation with unique codes, lookup, and cleanup. + A single RoomManager instance is used by the server. + """ + + def __init__(self) -> None: + """Initialize an empty room manager.""" self.rooms: dict[str, Room] = {} def _generate_code(self) -> str: + """Generate a unique 4-letter room code.""" while True: code = "".join(random.choices(string.ascii_uppercase, k=4)) if code not in self.rooms: return code def create_room(self) -> Room: + """ + Create a new room with a unique code. + + Returns: + The newly created Room. + """ code = self._generate_code() room = Room(code=code) self.rooms[code] = room return room def get_room(self, code: str) -> Optional[Room]: + """ + Get a room by its code (case-insensitive). + + Args: + code: The 4-letter room code. + + Returns: + The Room if found, None otherwise. + """ return self.rooms.get(code.upper()) - def remove_room(self, code: str): + def remove_room(self, code: str) -> None: + """ + Delete a room. + + Args: + code: The room code to remove. + """ if code in self.rooms: del self.rooms[code] def find_player_room(self, player_id: str) -> Optional[Room]: + """ + Find which room a player is in. + + Args: + player_id: The player ID to search for. + + Returns: + The Room containing the player, or None. + """ for room in self.rooms.values(): if player_id in room.players: return room