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:
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
|
||||
|
||||
Reference in New Issue
Block a user