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 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-01-25 00:10:26 -05:00
parent 39b78a2ba6
commit d9073f862c
5 changed files with 717 additions and 152 deletions

View File

@ -127,12 +127,12 @@
<label class="radio-label"> <label class="radio-label">
<input type="radio" name="joker-mode" value="lucky-swing"> <input type="radio" name="joker-mode" value="lucky-swing">
<span>Lucky Swing</span> <span>Lucky Swing</span>
<span class="rule-desc">1-2-3 decks - 1 Joker, -5 pt</span> <span class="rule-desc">1-3 decks: 1 Joker, -5 pts!</span>
</label> </label>
<label class="radio-label"> <label class="radio-label">
<input type="radio" name="joker-mode" value="eagle-eye"> <input type="radio" name="joker-mode" value="eagle-eye">
<span>Eagle-Eyed</span> <span>Eagle-Eyed</span>
<span class="rule-desc">★ = +2 pts, -4 pts paired</span> <span class="rule-desc">2 per deck, +2 pts / -4 paired</span>
</label> </label>
</div> </div>
</div> </div>
@ -204,6 +204,10 @@
<div class="game-main"> <div class="game-main">
<div class="game-header"> <div class="game-header">
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div> <div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="turn-info" id="turn-info">Your turn</div> <div class="turn-info" id="turn-info">Your turn</div>
<div class="score-info">Showing: <span id="your-score">0</span></div> <div class="score-info">Showing: <span id="your-score">0</span></div>
<div class="header-buttons"> <div class="header-buttons">
@ -212,11 +216,6 @@
</div> </div>
</div> </div>
<div id="active-rules-bar" class="active-rules-bar hidden">
<span class="rules-label">Rules:</span>
<span id="active-rules-list" class="rules-list"></span>
</div>
<div class="game-table"> <div class="game-table">
<div id="opponents-row" class="opponents-row"></div> <div id="opponents-row" class="opponents-row"></div>

View File

@ -463,8 +463,9 @@ input::placeholder {
/* Game Screen */ /* Game Screen */
.game-header { .game-header {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-columns: auto 1fr auto auto auto;
align-items: center; align-items: center;
gap: 15px;
padding: 10px 25px; padding: 10px 25px;
background: rgba(0,0,0,0.35); background: rgba(0,0,0,0.35);
border-radius: 0; border-radius: 0;
@ -475,22 +476,21 @@ input::placeholder {
} }
.game-header .round-info { .game-header .round-info {
justify-self: start;
font-weight: 600; font-weight: 600;
white-space: nowrap;
} }
.game-header .turn-info { .game-header .turn-info {
justify-self: center;
font-weight: 600; font-weight: 600;
color: #f4a460; color: #f4a460;
white-space: nowrap;
} }
.game-header .score-info { .game-header .score-info {
justify-self: center; white-space: nowrap;
} }
.game-header .header-buttons { .game-header .header-buttons {
justify-self: end;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@ -517,16 +517,17 @@ input::placeholder {
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
} }
/* Active Rules Bar */ /* Active Rules (in header) */
.active-rules-bar { .active-rules-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 6px;
padding: 6px 20px; font-size: 0.85rem;
background: rgba(0, 0, 0, 0.25); }
font-size: 0.8rem;
flex-wrap: wrap; .active-rules-bar.hidden {
display: none;
} }
.active-rules-bar .rules-label { .active-rules-bar .rules-label {
@ -536,18 +537,17 @@ input::placeholder {
.active-rules-bar .rules-list { .active-rules-bar .rules-list {
display: flex; display: flex;
gap: 6px; gap: 5px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center;
} }
.active-rules-bar .rule-tag { .active-rules-bar .rule-tag {
background: rgba(244, 164, 96, 0.25); background: rgba(244, 164, 96, 0.3);
color: #f4a460; color: #f4a460;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 600;
} }
/* Card Styles */ /* Card Styles */

View File

@ -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 Card value constants for 6-Card Golf.
DEFAULT_CARD_VALUES = {
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, 'A': 1,
'2': -2, '2': -2,
'3': 3, '3': 3,
@ -14,17 +30,19 @@ DEFAULT_CARD_VALUES = {
'J': 10, 'J': 10,
'Q': 10, 'Q': 10,
'K': 0, 'K': 0,
'': -2, # Joker (standard) '': -2, # Joker (standard mode)
} }
# House rule modifications (per RULES.md House Rules section) # --- House Rule Value Overrides ---
SUPER_KINGS_VALUE = -2 # K worth -2 instead of 0 SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
LUCKY_SEVENS_VALUE = 0 # 7 worth 0 instead of 7 TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
TEN_PENNY_VALUE = 1 # 10 worth 1 instead of 10 LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
LUCKY_SWING_JOKER_VALUE = -5 # Joker worth -5 in Lucky Swing mode
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. 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 value = LUCKY_SWING_JOKER_VALUE
elif rank_str == 'K' and options.get('super_kings'): elif rank_str == 'K' and options.get('super_kings'):
value = SUPER_KINGS_VALUE 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'): elif rank_str == '10' and options.get('ten_penny'):
value = TEN_PENNY_VALUE value = TEN_PENNY_VALUE

View File

@ -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 import random
from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional
from enum import Enum from enum import Enum
from typing import Optional
from constants import ( from constants import (
DEFAULT_CARD_VALUES, DEFAULT_CARD_VALUES,
@ -14,6 +33,8 @@ from constants import (
class Suit(Enum): class Suit(Enum):
"""Card suits for a standard deck."""
HEARTS = "hearts" HEARTS = "hearts"
DIAMONDS = "diamonds" DIAMONDS = "diamonds"
CLUBS = "clubs" CLUBS = "clubs"
@ -21,6 +42,17 @@ class Suit(Enum):
class Rank(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" ACE = "A"
TWO = "2" TWO = "2"
THREE = "3" THREE = "3"
@ -37,8 +69,8 @@ class Rank(Enum):
JOKER = "" JOKER = ""
# Derive RANK_VALUES from DEFAULT_CARD_VALUES (single source of truth in constants.py) # Map Rank enum to point values (derived from constants.py as single source of truth)
RANK_VALUES = {rank: DEFAULT_CARD_VALUES[rank.value] for rank in Rank} 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: 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 @dataclass
class Card: 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 suit: Suit
rank: Rank rank: Rank
face_up: bool = False face_up: bool = False
def to_dict(self, reveal: bool = False) -> dict: 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: if self.face_up or reveal:
return { return {
"suit": self.suit.value, "suit": self.suit.value,
@ -83,45 +133,98 @@ class Card:
return {"face_up": False} return {"face_up": False}
def value(self) -> int: def value(self) -> int:
"""Get base point value (without house rule modifications)."""
return RANK_VALUES[self.rank] return RANK_VALUES[self.rank]
class Deck: 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] = [] self.cards: list[Card] = []
# Build deck(s) with standard cards
for _ in range(num_decks): for _ in range(num_decks):
for suit in Suit: for suit in Suit:
for rank in Rank: for rank in Rank:
if rank != Rank.JOKER: if rank != Rank.JOKER:
self.cards.append(Card(suit, rank)) self.cards.append(Card(suit, rank))
# Standard jokers: 2 per deck, worth -2 each
if use_jokers and not lucky_swing: 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.HEARTS, Rank.JOKER))
self.cards.append(Card(Suit.SPADES, 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: if use_jokers and lucky_swing:
self.cards.append(Card(Suit.HEARTS, Rank.JOKER)) self.cards.append(Card(Suit.HEARTS, Rank.JOKER))
self.shuffle() self.shuffle()
def shuffle(self): def shuffle(self) -> None:
"""Randomize the order of cards in the deck."""
random.shuffle(self.cards) random.shuffle(self.cards)
def draw(self) -> Optional[Card]: 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: if self.cards:
return self.cards.pop() return self.cards.pop()
return None return None
def cards_remaining(self) -> int: def cards_remaining(self) -> int:
"""Return the number of cards left in the deck."""
return len(self.cards) return len(self.cards)
def add_cards(self, cards: list[Card]): def add_cards(self, cards: list[Card]) -> None:
"""Add cards to the deck and shuffle.""" """
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.cards.extend(cards)
self.shuffle() self.shuffle()
@dataclass @dataclass
class Player: 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 id: str
name: str name: str
cards: list[Card] = field(default_factory=list) cards: list[Card] = field(default_factory=list)
@ -130,54 +233,87 @@ class Player:
rounds_won: int = 0 rounds_won: int = 0
def all_face_up(self) -> bool: 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) 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): if 0 <= position < len(self.cards):
self.cards[position].face_up = True self.cards[position].face_up = True
def swap_card(self, position: int, new_card: Card) -> Card: 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] old_card = self.cards[position]
new_card.face_up = True new_card.face_up = True
self.cards[position] = new_card self.cards[position] = new_card
return old_card return old_card
def calculate_score(self, options: Optional["GameOptions"] = None) -> int: 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: if len(self.cards) != 6:
return 0 return 0
total = 0 total = 0
jack_pairs = 0 # Track paired Jacks for Wolfpack bonus 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): for col in range(3):
top_idx = col top_idx = col
bottom_idx = col + 3 bottom_idx = col + 3
top_card = self.cards[top_idx] top_card = self.cards[top_idx]
bottom_card = self.cards[bottom_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: if top_card.rank == bottom_card.rank:
# Track Jack pairs for Wolfpack # Track Jack pairs for Wolfpack bonus
if top_card.rank == Rank.JACK: if top_card.rank == Rank.JACK:
jack_pairs += 1 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 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 total -= 4
continue continue
# Normal matching pair scores 0
# Normal matching pair: scores 0 (skip adding values)
continue continue
else:
# Non-matching cards: add both values
total += get_card_value(top_card, options) total += get_card_value(top_card, options)
total += get_card_value(bottom_card, options) total += get_card_value(bottom_card, options)
# Wolfpack bonus: 2 pairs of Jacks = -5 pts # Wolfpack bonus: 2+ pairs of Jacks = -5 pts
if options and options.wolfpack and jack_pairs >= 2: if options and options.wolfpack and jack_pairs >= 2:
total -= 5 total -= 5
@ -185,44 +321,116 @@ class Player:
return total return total
def cards_to_dict(self, reveal: bool = False) -> list[dict]: 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] return [card.to_dict(reveal) for card in self.cards]
class GamePhase(Enum): class GamePhase(Enum):
WAITING = "waiting" """
INITIAL_FLIP = "initial_flip" Phases of a Golf game round.
PLAYING = "playing"
FINAL_TURN = "final_turn" Flow: WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER
ROUND_OVER = "round_over" After all rounds: GAME_OVER
GAME_OVER = "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 @dataclass
class GameOptions: class GameOptions:
# Standard options """
flip_on_discard: bool = False # Flip a card when discarding from deck Configuration options for game rules and house variants.
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
# House Rules - Point Modifiers These options can modify scoring, add special rules, and change gameplay.
lucky_swing: bool = False # Single joker worth -5 instead of two -2 jokers All options default to False/standard values for a classic Golf game.
super_kings: bool = False # Kings worth -2 instead of 0 """
ten_penny: bool = False # 10s worth 1 (like Ace) instead of 10
# House Rules - Bonuses/Penalties # --- Standard Options ---
knock_bonus: bool = False # First to reveal all cards gets -5 bonus flip_on_discard: bool = False
underdog_bonus: bool = False # Lowest score player gets -3 each hole """If True, player must flip a face-down card after discarding from deck."""
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
# House Rules - Special initial_flips: int = 2
eagle_eye: bool = False # Paired jokers double instead of cancel (-4 or -10) """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 @dataclass
class Game: 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) players: list[Player] = field(default_factory=list)
deck: Optional[Deck] = None deck: Optional[Deck] = None
discard_pile: list[Card] = field(default_factory=list) discard_pile: list[Card] = field(default_factory=list)
@ -232,7 +440,7 @@ class Game:
num_rounds: int = 1 num_rounds: int = 1
current_round: int = 1 current_round: int = 1
drawn_card: Optional[Card] = None 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 finisher_id: Optional[str] = None
players_with_final_turn: set = field(default_factory=set) players_with_final_turn: set = field(default_factory=set)
initial_flips_done: set = field(default_factory=set) initial_flips_done: set = field(default_factory=set)
@ -240,13 +448,18 @@ class Game:
@property @property
def flip_on_discard(self) -> bool: def flip_on_discard(self) -> bool:
"""Convenience property for flip_on_discard option."""
return self.options.flip_on_discard return self.options.flip_on_discard
def get_card_values(self) -> dict: def get_card_values(self) -> dict[str, int]:
"""Get current card values with house rules applied.""" """
Get card value mapping with house rules applied.
Returns:
Dict mapping rank strings to point values.
"""
values = DEFAULT_CARD_VALUES.copy() values = DEFAULT_CARD_VALUES.copy()
# Apply house rule modifications
if self.options.super_kings: if self.options.super_kings:
values['K'] = SUPER_KINGS_VALUE values['K'] = SUPER_KINGS_VALUE
if self.options.ten_penny: if self.options.ten_penny:
@ -254,45 +467,100 @@ class Game:
if self.options.lucky_swing: if self.options.lucky_swing:
values[''] = LUCKY_SWING_JOKER_VALUE values[''] = LUCKY_SWING_JOKER_VALUE
elif self.options.eagle_eye: elif self.options.eagle_eye:
values[''] = 2 # Eagle-eyed: +2 unpaired, -4 paired values[''] = 2 # +2 unpaired, -4 paired (handled in scoring)
return values return values
# -------------------------------------------------------------------------
# Player Management
# -------------------------------------------------------------------------
def add_player(self, player: Player) -> bool: 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: if len(self.players) >= 6:
return False return False
self.players.append(player) self.players.append(player)
return True return True
def remove_player(self, player_id: str) -> Optional[Player]: 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): for i, player in enumerate(self.players):
if player.id == player_id: if player.id == player_id:
return self.players.pop(i) return self.players.pop(i)
return None return None
def get_player(self, player_id: str) -> Optional[Player]: 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: for player in self.players:
if player.id == player_id: if player.id == player_id:
return player return player
return None return None
def current_player(self) -> Optional[Player]: def current_player(self) -> Optional[Player]:
"""Get the player whose turn it currently is."""
if self.players: if self.players:
return self.players[self.current_player_index] return self.players[self.current_player_index]
return None 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_decks = num_decks
self.num_rounds = num_rounds self.num_rounds = num_rounds
self.options = options or GameOptions() self.options = options or GameOptions()
self.current_round = 1 self.current_round = 1
self.start_round() 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.deck = Deck(
self.num_decks, self.num_decks,
use_jokers=self.options.use_jokers, use_jokers=self.options.use_jokers,
lucky_swing=self.options.lucky_swing lucky_swing=self.options.lucky_swing,
) )
self.discard_pile = [] self.discard_pile = []
self.drawn_card = None self.drawn_card = None
@ -310,13 +578,14 @@ class Game:
if card: if card:
player.cards.append(card) player.cards.append(card)
# Start discard pile with one card # Start discard pile with one face-up card
first_discard = self.deck.draw() first_discard = self.deck.draw()
if first_discard: if first_discard:
first_discard.face_up = True first_discard.face_up = True
self.discard_pile.append(first_discard) self.discard_pile.append(first_discard)
self.current_player_index = 0 self.current_player_index = 0
# Skip initial flip phase if 0 flips required # Skip initial flip phase if 0 flips required
if self.options.initial_flips == 0: if self.options.initial_flips == 0:
self.phase = GamePhase.PLAYING self.phase = GamePhase.PLAYING
@ -324,6 +593,19 @@ class Game:
self.phase = GamePhase.INITIAL_FLIP self.phase = GamePhase.INITIAL_FLIP
def flip_initial_cards(self, player_id: str, positions: list[int]) -> bool: 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: if self.phase != GamePhase.INITIAL_FLIP:
return False return False
@ -345,13 +627,31 @@ class Game:
self.initial_flips_done.add(player_id) 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): if len(self.initial_flips_done) == len(self.players):
self.phase = GamePhase.PLAYING self.phase = GamePhase.PLAYING
return True return True
# -------------------------------------------------------------------------
# Turn Actions
# -------------------------------------------------------------------------
def draw_card(self, player_id: str, source: str) -> Optional[Card]: 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() player = self.current_player()
if not player or player.id != player_id: if not player or player.id != player_id:
return None return None
@ -371,11 +671,11 @@ class Game:
self.drawn_card = card self.drawn_card = card
self.drawn_from_discard = False self.drawn_from_discard = False
return card return card
else:
# No cards available anywhere - end round gracefully # No cards available anywhere - end round gracefully
self._end_round() self._end_round()
return None return None
elif source == "discard" and self.discard_pile:
if source == "discard" and self.discard_pile:
card = self.discard_pile.pop() card = self.discard_pile.pop()
self.drawn_card = card self.drawn_card = card
self.drawn_from_discard = True self.drawn_from_discard = True
@ -384,29 +684,43 @@ class Game:
return None return None
def _reshuffle_discard_pile(self) -> Optional[Card]: 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: if len(self.discard_pile) <= 1:
# No cards to reshuffle (only top card or empty)
return None return None
# Keep the top card, take the rest # Keep the top card visible, reshuffle the rest
top_card = self.discard_pile[-1] top_card = self.discard_pile[-1]
cards_to_reshuffle = self.discard_pile[:-1] cards_to_reshuffle = self.discard_pile[:-1]
# Reset face_up for reshuffled cards
for card in cards_to_reshuffle: for card in cards_to_reshuffle:
card.face_up = False card.face_up = False
# Add to deck and shuffle
self.deck.add_cards(cards_to_reshuffle) self.deck.add_cards(cards_to_reshuffle)
# Keep only top card in discard pile
self.discard_pile = [top_card] self.discard_pile = [top_card]
# Draw from the newly shuffled deck
return self.deck.draw() return self.deck.draw()
def swap_card(self, player_id: str, position: int) -> Optional[Card]: 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() player = self.current_player()
if not player or player.id != player_id: if not player or player.id != player_id:
return None return None
@ -426,13 +740,31 @@ class Game:
return old_card return old_card
def can_discard_drawn(self) -> bool: 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: if self.drawn_from_discard:
return False return False
return True return True
def discard_drawn(self, player_id: str) -> bool: 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() player = self.current_player()
if not player or player.id != player_id: if not player or player.id != player_id:
return False return False
@ -440,7 +772,6 @@ class Game:
if self.drawn_card is None: if self.drawn_card is None:
return False return False
# Cannot discard if drawn from discard pile (must swap)
if not self.can_discard_drawn(): if not self.can_discard_drawn():
return False return False
@ -449,18 +780,29 @@ class Game:
self.drawn_card = None self.drawn_card = None
if self.flip_on_discard: 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) has_face_down = any(not card.face_up for card in player.cards)
if not has_face_down: if not has_face_down:
self._check_end_turn(player) self._check_end_turn(player)
# Otherwise, wait for flip_and_end_turn to be called # Otherwise, wait for flip_and_end_turn to be called
else: else:
# Version 2 (default): Just end the turn
self._check_end_turn(player) self._check_end_turn(player)
return True return True
def flip_and_end_turn(self, player_id: str, position: int) -> bool: 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() player = self.current_player()
if not player or player.id != player_id: if not player or player.id != player_id:
return False return False
@ -475,19 +817,35 @@ class Game:
self._check_end_turn(player) self._check_end_turn(player)
return True 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: if player.all_face_up() and self.finisher_id is None:
self.finisher_id = player.id self.finisher_id = player.id
self.phase = GamePhase.FINAL_TURN self.phase = GamePhase.FINAL_TURN
self.players_with_final_turn.add(player.id) self.players_with_final_turn.add(player.id)
# Move to next player
self._next_turn() 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: 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_index = (self.current_player_index + 1) % len(self.players)
next_player = self.players[next_index] next_player = self.players[next_index]
@ -501,22 +859,41 @@ class Game:
else: else:
self.current_player_index = (self.current_player_index + 1) % len(self.players) 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 self.phase = GamePhase.ROUND_OVER
# Reveal all cards and calculate scores # Reveal all cards and calculate base scores
for player in self.players: for player in self.players:
for card in player.cards: for card in player.cards:
card.face_up = True card.face_up = True
player.calculate_score(self.options) 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: if self.options.blackjack:
for player in self.players: for player in self.players:
if player.score == 21: if player.score == 21:
player.score = 0 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: if self.options.knock_penalty and self.finisher_id:
finisher = self.get_player(self.finisher_id) finisher = self.get_player(self.finisher_id)
if finisher: if finisher:
@ -524,27 +901,27 @@ class Game:
if finisher.score > min_score: if finisher.score > min_score:
finisher.score += 10 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: if self.options.knock_bonus and self.finisher_id:
finisher = self.get_player(self.finisher_id) finisher = self.get_player(self.finisher_id)
if finisher: if finisher:
finisher.score -= 5 finisher.score -= 5
# Apply underdog bonus (-3 to lowest scorer) # Underdog bonus: -3 to the lowest scorer(s)
if self.options.underdog_bonus: if self.options.underdog_bonus:
min_score = min(p.score for p in self.players) min_score = min(p.score for p in self.players)
for player in self.players: for player in self.players:
if player.score == min_score: if player.score == min_score:
player.score -= 3 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: if self.options.tied_shame:
from collections import Counter
score_counts = Counter(p.score for p in self.players) score_counts = Counter(p.score for p in self.players)
for player in self.players: for player in self.players:
if score_counts[player.score] > 1: if score_counts[player.score] > 1:
player.score += 5 player.score += 5
# Update cumulative totals
for player in self.players: for player in self.players:
player.total_score += player.score player.total_score += player.score
@ -555,6 +932,12 @@ class Game:
player.rounds_won += 1 player.rounds_won += 1
def start_next_round(self) -> bool: 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: if self.phase != GamePhase.ROUND_OVER:
return False return False
@ -566,12 +949,31 @@ class Game:
self.start_round() self.start_round()
return True return True
# -------------------------------------------------------------------------
# State Queries
# -------------------------------------------------------------------------
def discard_top(self) -> Optional[Card]: def discard_top(self) -> Optional[Card]:
"""Get the top card of the discard pile (if any)."""
if self.discard_pile: if self.discard_pile:
return self.discard_pile[-1] return self.discard_pile[-1]
return None return None
def get_state(self, for_player_id: str) -> dict: 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() current = self.current_player()
players_data = [] players_data = []

View File

@ -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 random
import string import string
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from fastapi import WebSocket from fastapi import WebSocket
from ai import assign_profile, assign_specific_profile, get_profile, release_profile
from game import Game, Player from game import Game, Player
from ai import assign_profile, release_profile, get_profile, assign_specific_profile
@dataclass @dataclass
class RoomPlayer: 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 id: str
name: str name: str
websocket: Optional[WebSocket] = None websocket: Optional[WebSocket] = None
@ -21,13 +48,42 @@ class RoomPlayer:
@dataclass @dataclass
class Room: 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 code: str
players: dict[str, RoomPlayer] = field(default_factory=dict) players: dict[str, RoomPlayer] = field(default_factory=dict)
game: Game = field(default_factory=Game) game: Game = field(default_factory=Game)
settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1}) 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 is_host = len(self.players) == 0
room_player = RoomPlayer( room_player = RoomPlayer(
id=player_id, id=player_id,
@ -37,21 +93,33 @@ class Room:
) )
self.players[player_id] = room_player self.players[player_id] = room_player
# Add to game
game_player = Player(id=player_id, name=name) game_player = Player(id=player_id, name=name)
self.game.add_player(game_player) self.game.add_player(game_player)
return room_player return room_player
def add_cpu_player(self, cpu_id: str, profile_name: Optional[str] = None) -> Optional[RoomPlayer]: def add_cpu_player(
# Get a CPU profile (specific or random) 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: if profile_name:
profile = assign_specific_profile(cpu_id, profile_name) profile = assign_specific_profile(cpu_id, profile_name)
else: else:
profile = assign_profile(cpu_id) profile = assign_profile(cpu_id)
if not profile: if not profile:
return None # Profile not available return None
room_player = RoomPlayer( room_player = RoomPlayer(
id=cpu_id, id=cpu_id,
@ -62,14 +130,27 @@ class Room:
) )
self.players[cpu_id] = room_player self.players[cpu_id] = room_player
# Add to game
game_player = Player(id=cpu_id, name=profile.name) game_player = Player(id=cpu_id, name=profile.name)
self.game.add_player(game_player) self.game.add_player(game_player)
return room_player return room_player
def remove_player(self, player_id: str) -> Optional[RoomPlayer]: def remove_player(self, player_id: str) -> Optional[RoomPlayer]:
if player_id in self.players: """
Remove a player from the room.
Handles host reassignment if the host leaves, and releases
CPU profiles back to the pool.
Args:
player_id: ID of the player to remove.
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) room_player = self.players.pop(player_id)
self.game.remove_player(player_id) self.game.remove_player(player_id)
@ -83,18 +164,30 @@ class Room:
next_host.is_host = True next_host.is_host = True
return room_player return room_player
return None
def get_player(self, player_id: str) -> Optional[RoomPlayer]: 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) return self.players.get(player_id)
def is_empty(self) -> bool: def is_empty(self) -> bool:
"""Check if the room has no players."""
return len(self.players) == 0 return len(self.players) == 0
def player_list(self) -> list[dict]: 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 = [] result = []
for p in self.players.values(): 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: if p.is_cpu:
profile = get_profile(p.id) profile = get_profile(p.id)
if profile: if profile:
@ -103,12 +196,21 @@ class Room:
return result return result
def get_cpu_players(self) -> list[RoomPlayer]: 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] return [p for p in self.players.values() if p.is_cpu]
def human_player_count(self) -> int: 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) 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(): for player_id, player in self.players.items():
if player_id != exclude and player.websocket and not player.is_cpu: if player_id != exclude and player.websocket and not player.is_cpu:
try: try:
@ -116,7 +218,14 @@ class Room:
except Exception: except Exception:
pass 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) player = self.players.get(player_id)
if player and player.websocket and not player.is_cpu: if player and player.websocket and not player.is_cpu:
try: try:
@ -126,29 +235,68 @@ class Room:
class RoomManager: 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] = {} self.rooms: dict[str, Room] = {}
def _generate_code(self) -> str: def _generate_code(self) -> str:
"""Generate a unique 4-letter room code."""
while True: while True:
code = "".join(random.choices(string.ascii_uppercase, k=4)) code = "".join(random.choices(string.ascii_uppercase, k=4))
if code not in self.rooms: if code not in self.rooms:
return code return code
def create_room(self) -> Room: def create_room(self) -> Room:
"""
Create a new room with a unique code.
Returns:
The newly created Room.
"""
code = self._generate_code() code = self._generate_code()
room = Room(code=code) room = Room(code=code)
self.rooms[code] = room self.rooms[code] = room
return room return room
def get_room(self, code: str) -> Optional[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()) 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: if code in self.rooms:
del self.rooms[code] del self.rooms[code]
def find_player_room(self, player_id: str) -> Optional[Room]: 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(): for room in self.rooms.values():
if player_id in room.players: if player_id in room.players:
return room return room