v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py - Add V3 feature docs (dealer rotation, dealing animation, round end reveal, column pair celebration, final turn urgency, opponent thinking, score tallying, card hover/selection, knock early drama, column pair indicator, swap animation improvements, draw source distinction, card value tooltips, active rules context, discard pile history, realistic card sounds) - Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements) - Add installation guide with Docker, systemd, and nginx setup - Add helper scripts (install.sh, dev-server.sh, docker-build.sh) - Add animation flow diagrams documentation - Add test files for handlers, rooms, and V3 features - Add e2e test specs for V3 features - Update README with complete project structure and current tech stack - Update CLAUDE.md with full architecture tree and server layer descriptions - Update .env.example to reflect PostgreSQL (remove SQLite references) - Update .gitignore to exclude virtualenv files, .claude/, and .db files - Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg) - Remove obsolete game_log.py (SQLite) and games.db Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
845
server/main.py
845
server/main.py
@@ -9,6 +9,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
import redis.asyncio as redis
|
||||
|
||||
@@ -16,6 +17,7 @@ from config import config
|
||||
from room import RoomManager, Room
|
||||
from game import GamePhase, GameOptions
|
||||
from ai import GolfAI, process_cpu_turn, get_all_profiles, reset_all_profiles, cleanup_room_profiles
|
||||
from handlers import HANDLERS, ConnectionContext
|
||||
from services.game_logger import GameLogger, get_logger, set_logger
|
||||
|
||||
# Import production components
|
||||
@@ -79,127 +81,97 @@ async def _periodic_leaderboard_refresh():
|
||||
logger.error(f"Leaderboard refresh failed: {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler for async service initialization."""
|
||||
global _user_store, _auth_service, _admin_service, _stats_service, _replay_service
|
||||
global _spectator_manager, _leaderboard_refresh_task, _redis_client, _rate_limiter
|
||||
async def _init_redis():
|
||||
"""Initialize Redis client and rate limiter."""
|
||||
global _redis_client, _rate_limiter
|
||||
try:
|
||||
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
||||
await _redis_client.ping()
|
||||
logger.info("Redis client connected")
|
||||
|
||||
# Note: Uvicorn handles SIGINT/SIGTERM and triggers lifespan cleanup automatically
|
||||
if config.RATE_LIMIT_ENABLED:
|
||||
from services.ratelimit import get_rate_limiter
|
||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||
logger.info("Rate limiter initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||
_redis_client = None
|
||||
_rate_limiter = None
|
||||
|
||||
# Initialize Redis client (for rate limiting, health checks, etc.)
|
||||
if config.REDIS_URL:
|
||||
try:
|
||||
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
||||
await _redis_client.ping()
|
||||
logger.info("Redis client connected")
|
||||
|
||||
# Initialize rate limiter
|
||||
if config.RATE_LIMIT_ENABLED:
|
||||
from services.ratelimit import get_rate_limiter
|
||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||
logger.info("Rate limiter initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||
_redis_client = None
|
||||
_rate_limiter = None
|
||||
async def _init_database_services():
|
||||
"""Initialize all PostgreSQL-dependent services."""
|
||||
global _user_store, _auth_service, _admin_service, _stats_service
|
||||
global _replay_service, _spectator_manager, _leaderboard_refresh_task
|
||||
|
||||
# Initialize auth, admin, and stats services (requires PostgreSQL)
|
||||
if config.POSTGRES_URL:
|
||||
try:
|
||||
from stores.user_store import get_user_store
|
||||
from stores.event_store import get_event_store
|
||||
from services.auth_service import get_auth_service
|
||||
from services.admin_service import get_admin_service
|
||||
from services.stats_service import StatsService, set_stats_service
|
||||
from routers.auth import set_auth_service
|
||||
from routers.admin import set_admin_service
|
||||
from routers.stats import set_stats_service as set_stats_router_service
|
||||
from routers.stats import set_auth_service as set_stats_auth_service
|
||||
from stores.user_store import get_user_store
|
||||
from stores.event_store import get_event_store
|
||||
from services.auth_service import get_auth_service
|
||||
from services.admin_service import get_admin_service
|
||||
from services.stats_service import StatsService, set_stats_service
|
||||
from routers.auth import set_auth_service
|
||||
from routers.admin import set_admin_service
|
||||
from routers.stats import set_stats_service as set_stats_router_service
|
||||
from routers.stats import set_auth_service as set_stats_auth_service
|
||||
|
||||
logger.info("Initializing auth services...")
|
||||
_user_store = await get_user_store(config.POSTGRES_URL)
|
||||
_auth_service = await get_auth_service(_user_store)
|
||||
set_auth_service(_auth_service)
|
||||
logger.info("Auth services initialized successfully")
|
||||
# Auth
|
||||
_user_store = await get_user_store(config.POSTGRES_URL)
|
||||
_auth_service = await get_auth_service(_user_store)
|
||||
set_auth_service(_auth_service)
|
||||
logger.info("Auth services initialized")
|
||||
|
||||
# Initialize admin service
|
||||
logger.info("Initializing admin services...")
|
||||
_admin_service = await get_admin_service(
|
||||
pool=_user_store.pool,
|
||||
user_store=_user_store,
|
||||
state_cache=None, # Will add Redis state cache when available
|
||||
)
|
||||
set_admin_service(_admin_service)
|
||||
logger.info("Admin services initialized successfully")
|
||||
|
||||
# Initialize stats service
|
||||
logger.info("Initializing stats services...")
|
||||
_event_store = await get_event_store(config.POSTGRES_URL)
|
||||
_stats_service = StatsService(_user_store.pool, _event_store)
|
||||
set_stats_service(_stats_service)
|
||||
set_stats_router_service(_stats_service)
|
||||
set_stats_auth_service(_auth_service)
|
||||
logger.info("Stats services initialized successfully")
|
||||
|
||||
# Initialize game logger (uses event_store for move logging)
|
||||
logger.info("Initializing game logger...")
|
||||
_game_logger = GameLogger(_event_store)
|
||||
set_logger(_game_logger)
|
||||
logger.info("Game logger initialized with PostgreSQL backend")
|
||||
|
||||
# Initialize replay service
|
||||
logger.info("Initializing replay services...")
|
||||
from services.replay_service import get_replay_service, set_replay_service
|
||||
from services.spectator import get_spectator_manager
|
||||
from routers.replay import (
|
||||
set_replay_service as set_replay_router_service,
|
||||
set_auth_service as set_replay_auth_service,
|
||||
set_spectator_manager as set_replay_spectator,
|
||||
set_room_manager as set_replay_room_manager,
|
||||
)
|
||||
_replay_service = await get_replay_service(_user_store.pool, _event_store)
|
||||
_spectator_manager = get_spectator_manager()
|
||||
set_replay_service(_replay_service)
|
||||
set_replay_router_service(_replay_service)
|
||||
set_replay_auth_service(_auth_service)
|
||||
set_replay_spectator(_spectator_manager)
|
||||
set_replay_room_manager(room_manager)
|
||||
logger.info("Replay services initialized successfully")
|
||||
|
||||
# Start periodic leaderboard refresh task
|
||||
_leaderboard_refresh_task = asyncio.create_task(_periodic_leaderboard_refresh())
|
||||
logger.info("Leaderboard refresh task started")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize services: {e}")
|
||||
raise
|
||||
else:
|
||||
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
||||
|
||||
# Set up health check dependencies
|
||||
from routers.health import set_health_dependencies
|
||||
db_pool = _user_store.pool if _user_store else None
|
||||
set_health_dependencies(
|
||||
db_pool=db_pool,
|
||||
redis_client=_redis_client,
|
||||
room_manager=room_manager,
|
||||
# Admin
|
||||
_admin_service = await get_admin_service(
|
||||
pool=_user_store.pool,
|
||||
user_store=_user_store,
|
||||
state_cache=None,
|
||||
)
|
||||
set_admin_service(_admin_service)
|
||||
logger.info("Admin services initialized")
|
||||
|
||||
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
|
||||
# Stats + event store
|
||||
_event_store = await get_event_store(config.POSTGRES_URL)
|
||||
_stats_service = StatsService(_user_store.pool, _event_store)
|
||||
set_stats_service(_stats_service)
|
||||
set_stats_router_service(_stats_service)
|
||||
set_stats_auth_service(_auth_service)
|
||||
logger.info("Stats services initialized")
|
||||
|
||||
yield
|
||||
# Game logger
|
||||
_game_logger = GameLogger(_event_store)
|
||||
set_logger(_game_logger)
|
||||
logger.info("Game logger initialized")
|
||||
|
||||
# Graceful shutdown
|
||||
logger.info("Shutdown initiated...")
|
||||
# Replay + spectator
|
||||
from services.replay_service import get_replay_service, set_replay_service
|
||||
from services.spectator import get_spectator_manager
|
||||
from routers.replay import (
|
||||
set_replay_service as set_replay_router_service,
|
||||
set_auth_service as set_replay_auth_service,
|
||||
set_spectator_manager as set_replay_spectator,
|
||||
set_room_manager as set_replay_room_manager,
|
||||
)
|
||||
_replay_service = await get_replay_service(_user_store.pool, _event_store)
|
||||
_spectator_manager = get_spectator_manager()
|
||||
set_replay_service(_replay_service)
|
||||
set_replay_router_service(_replay_service)
|
||||
set_replay_auth_service(_auth_service)
|
||||
set_replay_spectator(_spectator_manager)
|
||||
set_replay_room_manager(room_manager)
|
||||
logger.info("Replay services initialized")
|
||||
|
||||
# Signal shutdown to all components
|
||||
# Periodic leaderboard refresh
|
||||
_leaderboard_refresh_task = asyncio.create_task(_periodic_leaderboard_refresh())
|
||||
logger.info("Leaderboard refresh task started")
|
||||
|
||||
|
||||
async def _shutdown_services():
|
||||
"""Gracefully shut down all services."""
|
||||
_shutdown_event.set()
|
||||
|
||||
# Close all WebSocket connections gracefully
|
||||
await _close_all_websockets()
|
||||
|
||||
# Clean up all rooms and release CPU profiles
|
||||
# Clean up rooms and CPU profiles
|
||||
for room in list(room_manager.rooms.values()):
|
||||
for cpu in list(room.get_cpu_players()):
|
||||
room.remove_player(cpu.id)
|
||||
@@ -207,7 +179,6 @@ async def lifespan(app: FastAPI):
|
||||
reset_all_profiles()
|
||||
logger.info("All rooms and CPU profiles cleaned up")
|
||||
|
||||
# Cancel background tasks
|
||||
if _leaderboard_refresh_task:
|
||||
_leaderboard_refresh_task.cancel()
|
||||
try:
|
||||
@@ -234,11 +205,40 @@ async def lifespan(app: FastAPI):
|
||||
close_admin_service()
|
||||
await close_user_store()
|
||||
|
||||
# Close Redis connection
|
||||
if _redis_client:
|
||||
await _redis_client.close()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler for async service initialization."""
|
||||
if config.REDIS_URL:
|
||||
await _init_redis()
|
||||
|
||||
if config.POSTGRES_URL:
|
||||
try:
|
||||
await _init_database_services()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize services: {e}")
|
||||
raise
|
||||
else:
|
||||
logger.warning("POSTGRES_URL not configured - auth/admin/stats endpoints will not work")
|
||||
|
||||
# Set up health check dependencies
|
||||
from routers.health import set_health_dependencies
|
||||
set_health_dependencies(
|
||||
db_pool=_user_store.pool if _user_store else None,
|
||||
redis_client=_redis_client,
|
||||
room_manager=room_manager,
|
||||
)
|
||||
|
||||
logger.info(f"Golf server started (environment={config.ENVIRONMENT})")
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutdown initiated...")
|
||||
await _shutdown_services()
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
|
||||
@@ -467,11 +467,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket auth failed: {e}")
|
||||
|
||||
# Each connection gets a unique ID (allows multi-tab play)
|
||||
connection_id = str(uuid.uuid4())
|
||||
player_id = connection_id
|
||||
|
||||
# Track auth user separately for stats/limits (can be None)
|
||||
auth_user_id = str(authenticated_user.id) if authenticated_user else None
|
||||
|
||||
if authenticated_user:
|
||||
@@ -479,556 +475,34 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
else:
|
||||
logger.debug(f"WebSocket connected anonymously as {connection_id}")
|
||||
|
||||
current_room: Room | None = None
|
||||
ctx = ConnectionContext(
|
||||
websocket=websocket,
|
||||
connection_id=connection_id,
|
||||
player_id=connection_id,
|
||||
auth_user_id=auth_user_id,
|
||||
authenticated_user=authenticated_user,
|
||||
)
|
||||
|
||||
# Shared dependencies passed to every handler
|
||||
handler_deps = dict(
|
||||
room_manager=room_manager,
|
||||
count_user_games=count_user_games,
|
||||
max_concurrent=MAX_CONCURRENT_GAMES,
|
||||
broadcast_game_state=broadcast_game_state,
|
||||
check_and_run_cpu_turn=check_and_run_cpu_turn,
|
||||
handle_player_leave=handle_player_leave,
|
||||
cleanup_room_profiles=cleanup_room_profiles,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "create_room":
|
||||
# Check concurrent game limit for authenticated users
|
||||
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
|
||||
})
|
||||
continue
|
||||
|
||||
player_name = data.get("player_name", "Player")
|
||||
# Use authenticated user's name if available
|
||||
if authenticated_user and authenticated_user.display_name:
|
||||
player_name = authenticated_user.display_name
|
||||
room = room_manager.create_room()
|
||||
room.add_player(player_id, player_name, websocket, auth_user_id)
|
||||
current_room = room
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "room_created",
|
||||
"room_code": room.code,
|
||||
"player_id": player_id,
|
||||
"authenticated": authenticated_user is not None,
|
||||
})
|
||||
|
||||
await room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": room.player_list(),
|
||||
})
|
||||
|
||||
elif msg_type == "join_room":
|
||||
room_code = data.get("room_code", "").upper()
|
||||
player_name = data.get("player_name", "Player")
|
||||
|
||||
# Check concurrent game limit for authenticated users
|
||||
if auth_user_id and count_user_games(auth_user_id) >= MAX_CONCURRENT_GAMES:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"Maximum {MAX_CONCURRENT_GAMES} concurrent games allowed",
|
||||
})
|
||||
continue
|
||||
|
||||
room = room_manager.get_room(room_code)
|
||||
if not room:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Room not found",
|
||||
})
|
||||
continue
|
||||
|
||||
if len(room.players) >= 6:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Room is full",
|
||||
})
|
||||
continue
|
||||
|
||||
if room.game.phase != GamePhase.WAITING:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Game already in progress",
|
||||
})
|
||||
continue
|
||||
|
||||
# Use authenticated user's name if available
|
||||
if authenticated_user and authenticated_user.display_name:
|
||||
player_name = authenticated_user.display_name
|
||||
room.add_player(player_id, player_name, websocket, auth_user_id)
|
||||
current_room = room
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "room_joined",
|
||||
"room_code": room.code,
|
||||
"player_id": player_id,
|
||||
"authenticated": authenticated_user is not None,
|
||||
})
|
||||
|
||||
await room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": room.player_list(),
|
||||
})
|
||||
|
||||
elif msg_type == "get_cpu_profiles":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "cpu_profiles",
|
||||
"profiles": get_all_profiles(),
|
||||
})
|
||||
|
||||
elif msg_type == "add_cpu":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
room_player = current_room.get_player(player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Only the host can add CPU players",
|
||||
})
|
||||
continue
|
||||
|
||||
if len(current_room.players) >= 6:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Room is full",
|
||||
})
|
||||
continue
|
||||
|
||||
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
|
||||
profile_name = data.get("profile_name")
|
||||
|
||||
cpu_player = current_room.add_cpu_player(cpu_id, profile_name)
|
||||
if not cpu_player:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "CPU profile not available",
|
||||
})
|
||||
continue
|
||||
|
||||
await current_room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": current_room.player_list(),
|
||||
})
|
||||
|
||||
elif msg_type == "remove_cpu":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
room_player = current_room.get_player(player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
continue
|
||||
|
||||
# Remove the last CPU player
|
||||
cpu_players = current_room.get_cpu_players()
|
||||
if cpu_players:
|
||||
current_room.remove_player(cpu_players[-1].id)
|
||||
await current_room.broadcast({
|
||||
"type": "player_joined",
|
||||
"players": current_room.player_list(),
|
||||
})
|
||||
|
||||
elif msg_type == "start_game":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
room_player = current_room.get_player(player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Only the host can start the game",
|
||||
})
|
||||
continue
|
||||
|
||||
if len(current_room.players) < 2:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Need at least 2 players",
|
||||
})
|
||||
continue
|
||||
|
||||
num_decks = data.get("decks", 1)
|
||||
num_rounds = data.get("rounds", 1)
|
||||
|
||||
# Parse deck colors (validate against allowed colors)
|
||||
allowed_colors = {
|
||||
"red", "blue", "gold", "teal", "purple", "orange", "yellow",
|
||||
"green", "pink", "cyan", "brown", "slate"
|
||||
}
|
||||
raw_deck_colors = data.get("deck_colors", ["red", "blue", "gold"])
|
||||
deck_colors = [c for c in raw_deck_colors if c in allowed_colors]
|
||||
if not deck_colors:
|
||||
deck_colors = ["red", "blue", "gold"]
|
||||
|
||||
# Build game options
|
||||
options = GameOptions(
|
||||
# Standard options
|
||||
flip_mode=data.get("flip_mode", "never"),
|
||||
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
|
||||
knock_penalty=data.get("knock_penalty", False),
|
||||
use_jokers=data.get("use_jokers", False),
|
||||
# House Rules - Point Modifiers
|
||||
lucky_swing=data.get("lucky_swing", False),
|
||||
super_kings=data.get("super_kings", False),
|
||||
ten_penny=data.get("ten_penny", False),
|
||||
# House Rules - Bonuses/Penalties
|
||||
knock_bonus=data.get("knock_bonus", False),
|
||||
underdog_bonus=data.get("underdog_bonus", False),
|
||||
tied_shame=data.get("tied_shame", False),
|
||||
blackjack=data.get("blackjack", False),
|
||||
eagle_eye=data.get("eagle_eye", False),
|
||||
wolfpack=data.get("wolfpack", False),
|
||||
# House Rules - New Variants
|
||||
flip_as_action=data.get("flip_as_action", False),
|
||||
four_of_a_kind=data.get("four_of_a_kind", False),
|
||||
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
|
||||
one_eyed_jacks=data.get("one_eyed_jacks", False),
|
||||
knock_early=data.get("knock_early", False),
|
||||
# Multi-deck card back colors
|
||||
deck_colors=deck_colors,
|
||||
)
|
||||
|
||||
# Validate settings
|
||||
num_decks = max(1, min(3, num_decks))
|
||||
num_rounds = max(1, min(18, num_rounds))
|
||||
|
||||
async with current_room.game_lock:
|
||||
current_room.game.start_game(num_decks, num_rounds, options)
|
||||
|
||||
# Log game start for AI analysis
|
||||
game_logger = get_logger()
|
||||
if game_logger:
|
||||
current_room.game_log_id = game_logger.log_game_start(
|
||||
room_code=current_room.code,
|
||||
num_players=len(current_room.players),
|
||||
options=options,
|
||||
)
|
||||
|
||||
# CPU players do their initial flips immediately (if required)
|
||||
if options.initial_flips > 0:
|
||||
for cpu in current_room.get_cpu_players():
|
||||
positions = GolfAI.choose_initial_flips(options.initial_flips)
|
||||
current_room.game.flip_initial_cards(cpu.id, positions)
|
||||
|
||||
# Send game started to all human players with their personal view
|
||||
for pid, player in current_room.players.items():
|
||||
if player.websocket and not player.is_cpu:
|
||||
game_state = current_room.game.get_state(pid)
|
||||
await player.websocket.send_json({
|
||||
"type": "game_started",
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
# Check if it's a CPU's turn to start
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "flip_initial":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
positions = data.get("positions", [])
|
||||
async with current_room.game_lock:
|
||||
if current_room.game.flip_initial_cards(player_id, positions):
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
# Check if it's a CPU's turn
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "draw":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
source = data.get("source", "deck")
|
||||
async with current_room.game_lock:
|
||||
# Capture discard top before draw (for logging decision context)
|
||||
discard_before_draw = current_room.game.discard_top()
|
||||
card = current_room.game.draw_card(player_id, source)
|
||||
|
||||
if card:
|
||||
# Log draw decision for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if player:
|
||||
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="take_discard" if source == "discard" else "draw_deck",
|
||||
card=card,
|
||||
game=current_room.game,
|
||||
decision_reason=reason,
|
||||
)
|
||||
|
||||
# Send drawn card only to the player who drew
|
||||
await websocket.send_json({
|
||||
"type": "card_drawn",
|
||||
"card": card.to_dict(),
|
||||
"source": source,
|
||||
})
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
elif msg_type == "swap":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with current_room.game_lock:
|
||||
# Capture drawn card before swap for logging
|
||||
drawn_card = current_room.game.drawn_card
|
||||
player = current_room.game.get_player(player_id)
|
||||
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
|
||||
|
||||
discarded = current_room.game.swap_card(player_id, position)
|
||||
|
||||
if discarded:
|
||||
# Log swap decision for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and drawn_card and player:
|
||||
old_rank = old_card.rank.value if old_card else "?"
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="swap",
|
||||
card=drawn_card,
|
||||
position=position,
|
||||
game=current_room.game,
|
||||
decision_reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
# Let client swap animation complete (~550ms), then pause to show result
|
||||
# Total 1.0s = 550ms animation + 450ms visible pause
|
||||
await asyncio.sleep(1.0)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "discard":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
# Capture drawn card before discard for logging
|
||||
drawn_card = current_room.game.drawn_card
|
||||
player = current_room.game.get_player(player_id)
|
||||
|
||||
if current_room.game.discard_drawn(player_id):
|
||||
# Log discard decision for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and drawn_card and player:
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="discard",
|
||||
card=drawn_card,
|
||||
game=current_room.game,
|
||||
decision_reason=f"discarded {drawn_card.rank.value}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
if current_room.game.flip_on_discard:
|
||||
# Check if player has face-down cards to flip
|
||||
player = current_room.game.get_player(player_id)
|
||||
has_face_down = player and any(not c.face_up for c in player.cards)
|
||||
|
||||
if has_face_down:
|
||||
await websocket.send_json({
|
||||
"type": "can_flip",
|
||||
"optional": current_room.game.flip_is_optional,
|
||||
})
|
||||
else:
|
||||
# Let client animation complete before CPU turn
|
||||
await asyncio.sleep(0.5)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
else:
|
||||
# Turn ended - let client animation complete before CPU turn
|
||||
# (player discard swoop animation is ~500ms: 350ms swoop + 150ms settle)
|
||||
logger.debug(f"Player discarded, waiting 0.5s before CPU turn")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.debug(f"Post-discard delay complete, checking for CPU turn")
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "cancel_draw":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
if current_room.game.cancel_discard_draw(player_id):
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
elif msg_type == "flip_card":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
current_room.game.flip_and_end_turn(player_id, position)
|
||||
|
||||
# Log flip decision for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and player and 0 <= position < len(player.cards):
|
||||
flipped_card = player.cards[position]
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="flip",
|
||||
card=flipped_card,
|
||||
position=position,
|
||||
game=current_room.game,
|
||||
decision_reason=f"flipped card at position {position}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "skip_flip":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if current_room.game.skip_flip_and_end_turn(player_id):
|
||||
# Log skip flip decision for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and player:
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="skip_flip",
|
||||
card=None,
|
||||
game=current_room.game,
|
||||
decision_reason="skipped optional flip (endgame mode)",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "flip_as_action":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
position = data.get("position", 0)
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if current_room.game.flip_card_as_action(player_id, position):
|
||||
# Log flip-as-action for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and player and 0 <= position < len(player.cards):
|
||||
flipped_card = player.cards[position]
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="flip_as_action",
|
||||
card=flipped_card,
|
||||
position=position,
|
||||
game=current_room.game,
|
||||
decision_reason=f"used flip-as-action to reveal position {position}",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "knock_early":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
player = current_room.game.get_player(player_id)
|
||||
if current_room.game.knock_early(player_id):
|
||||
# Log knock early for human player
|
||||
game_logger = get_logger()
|
||||
if game_logger and current_room.game_log_id and player:
|
||||
face_down_count = sum(1 for c in player.cards if not c.face_up)
|
||||
game_logger.log_move(
|
||||
game_id=current_room.game_log_id,
|
||||
player=player,
|
||||
is_cpu=False,
|
||||
action="knock_early",
|
||||
card=None,
|
||||
game=current_room.game,
|
||||
decision_reason=f"knocked early, revealing {face_down_count} hidden cards",
|
||||
)
|
||||
|
||||
await broadcast_game_state(current_room)
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
|
||||
elif msg_type == "next_round":
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
room_player = current_room.get_player(player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
continue
|
||||
|
||||
async with current_room.game_lock:
|
||||
if current_room.game.start_next_round():
|
||||
# CPU players do their initial flips
|
||||
for cpu in current_room.get_cpu_players():
|
||||
positions = GolfAI.choose_initial_flips()
|
||||
current_room.game.flip_initial_cards(cpu.id, positions)
|
||||
|
||||
for pid, player in current_room.players.items():
|
||||
if player.websocket and not player.is_cpu:
|
||||
game_state = current_room.game.get_state(pid)
|
||||
await player.websocket.send_json({
|
||||
"type": "round_started",
|
||||
"game_state": game_state,
|
||||
})
|
||||
|
||||
await check_and_run_cpu_turn(current_room)
|
||||
else:
|
||||
# Game over
|
||||
await broadcast_game_state(current_room)
|
||||
|
||||
elif msg_type == "leave_room":
|
||||
if current_room:
|
||||
await handle_player_leave(current_room, player_id)
|
||||
current_room = None
|
||||
|
||||
elif msg_type == "leave_game":
|
||||
# Player leaves during an active game
|
||||
if current_room:
|
||||
await handle_player_leave(current_room, player_id)
|
||||
current_room = None
|
||||
|
||||
elif msg_type == "end_game":
|
||||
# Host ends the game for everyone
|
||||
if not current_room:
|
||||
continue
|
||||
|
||||
room_player = current_room.get_player(player_id)
|
||||
if not room_player or not room_player.is_host:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "Only the host can end the game",
|
||||
})
|
||||
continue
|
||||
|
||||
# Notify all players that the game has ended
|
||||
await current_room.broadcast({
|
||||
"type": "game_ended",
|
||||
"reason": "Host ended the game",
|
||||
})
|
||||
|
||||
# Clean up the room
|
||||
room_code = current_room.code
|
||||
for cpu in list(current_room.get_cpu_players()):
|
||||
current_room.remove_player(cpu.id)
|
||||
cleanup_room_profiles(room_code)
|
||||
room_manager.remove_room(room_code)
|
||||
current_room = None
|
||||
|
||||
handler = HANDLERS.get(data.get("type"))
|
||||
if handler:
|
||||
await handler(data, ctx, **handler_deps)
|
||||
except WebSocketDisconnect:
|
||||
if current_room:
|
||||
await handle_player_leave(current_room, player_id)
|
||||
if ctx.current_room:
|
||||
await handle_player_leave(ctx.current_room, ctx.player_id)
|
||||
|
||||
|
||||
async def _process_stats_safe(room: Room):
|
||||
@@ -1192,68 +666,17 @@ if os.path.exists(client_path):
|
||||
async def serve_index():
|
||||
return FileResponse(os.path.join(client_path, "index.html"))
|
||||
|
||||
@app.get("/style.css")
|
||||
async def serve_css():
|
||||
return FileResponse(os.path.join(client_path, "style.css"), media_type="text/css")
|
||||
|
||||
@app.get("/app.js")
|
||||
async def serve_js():
|
||||
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/card-manager.js")
|
||||
async def serve_card_manager():
|
||||
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/state-differ.js")
|
||||
async def serve_state_differ():
|
||||
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/animation-queue.js")
|
||||
async def serve_animation_queue():
|
||||
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/timing-config.js")
|
||||
async def serve_timing_config():
|
||||
return FileResponse(os.path.join(client_path, "timing-config.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/leaderboard.js")
|
||||
async def serve_leaderboard_js():
|
||||
return FileResponse(os.path.join(client_path, "leaderboard.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/golfball-logo.svg")
|
||||
async def serve_golfball_logo():
|
||||
return FileResponse(os.path.join(client_path, "golfball-logo.svg"), media_type="image/svg+xml")
|
||||
|
||||
# Admin dashboard
|
||||
@app.get("/admin")
|
||||
async def serve_admin():
|
||||
return FileResponse(os.path.join(client_path, "admin.html"))
|
||||
|
||||
@app.get("/admin.css")
|
||||
async def serve_admin_css():
|
||||
return FileResponse(os.path.join(client_path, "admin.css"), media_type="text/css")
|
||||
|
||||
@app.get("/admin.js")
|
||||
async def serve_admin_js():
|
||||
return FileResponse(os.path.join(client_path, "admin.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/replay.js")
|
||||
async def serve_replay_js():
|
||||
return FileResponse(os.path.join(client_path, "replay.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/card-animations.js")
|
||||
async def serve_card_animations_js():
|
||||
return FileResponse(os.path.join(client_path, "card-animations.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/anime.min.js")
|
||||
async def serve_anime_js():
|
||||
return FileResponse(os.path.join(client_path, "anime.min.js"), media_type="application/javascript")
|
||||
|
||||
# Serve replay page for share links
|
||||
@app.get("/replay/{share_code}")
|
||||
async def serve_replay_page(share_code: str):
|
||||
return FileResponse(os.path.join(client_path, "index.html"))
|
||||
|
||||
# Mount static files for everything else (JS, CSS, SVG, etc.)
|
||||
app.mount("/", StaticFiles(directory=client_path), name="static")
|
||||
|
||||
|
||||
def run():
|
||||
"""Run the server using uvicorn."""
|
||||
|
||||
Reference in New Issue
Block a user