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:
parent
39b78a2ba6
commit
d9073f862c
@ -127,12 +127,12 @@
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="joker-mode" value="lucky-swing">
|
||||
<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 class="radio-label">
|
||||
<input type="radio" name="joker-mode" value="eagle-eye">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -204,6 +204,10 @@
|
||||
<div class="game-main">
|
||||
<div class="game-header">
|
||||
<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="score-info">Showing: <span id="your-score">0</span></div>
|
||||
<div class="header-buttons">
|
||||
@ -212,11 +216,6 @@
|
||||
</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 id="opponents-row" class="opponents-row"></div>
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
586
server/game.py
586
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 = []
|
||||
|
||||
200
server/room.py
200
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user