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