golfgame/server/handlers.py
adlee-was-taken 9fc6b83bba 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>
2026-02-14 10:03:45 -05:00

507 lines
18 KiB
Python

"""WebSocket message handlers for the Golf card game.
Each handler corresponds to a single message type from the client.
Handlers are dispatched via the HANDLERS dict in main.py.
"""
import asyncio
import logging
import uuid
from dataclasses import dataclass
from typing import Optional
from fastapi import WebSocket
from game import GamePhase, GameOptions
from ai import GolfAI, get_all_profiles
from room import Room
from services.game_logger import get_logger
logger = logging.getLogger(__name__)
@dataclass
class ConnectionContext:
"""State tracked per WebSocket connection."""
websocket: WebSocket
connection_id: str
player_id: str
auth_user_id: Optional[str]
authenticated_user: object # Optional[User]
current_room: Optional[Room] = None
def log_human_action(room: Room, player, action: str, card=None, position=None, reason: str = ""):
"""Log a human player's game action (shared helper for all handlers)."""
game_logger = get_logger()
if game_logger and room.game_log_id and player:
game_logger.log_move(
game_id=room.game_log_id,
player=player,
is_cpu=False,
action=action,
card=card,
position=position,
game=room.game,
decision_reason=reason,
)
# ---------------------------------------------------------------------------
# Lobby / Room handlers
# ---------------------------------------------------------------------------
async def handle_create_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({
"type": "error",
"message": f"Maximum {max_concurrent} concurrent games allowed",
})
return
player_name = data.get("player_name", "Player")
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
room = room_manager.create_room()
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room
await ctx.websocket.send_json({
"type": "room_created",
"room_code": room.code,
"player_id": ctx.player_id,
"authenticated": ctx.authenticated_user is not None,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
async def handle_join_room(data: dict, ctx: ConnectionContext, *, room_manager, count_user_games, max_concurrent, **kw) -> None:
room_code = data.get("room_code", "").upper()
player_name = data.get("player_name", "Player")
if ctx.auth_user_id and count_user_games(ctx.auth_user_id) >= max_concurrent:
await ctx.websocket.send_json({
"type": "error",
"message": f"Maximum {max_concurrent} concurrent games allowed",
})
return
room = room_manager.get_room(room_code)
if not room:
await ctx.websocket.send_json({"type": "error", "message": "Room not found"})
return
if len(room.players) >= 6:
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
return
if room.game.phase != GamePhase.WAITING:
await ctx.websocket.send_json({"type": "error", "message": "Game already in progress"})
return
if ctx.authenticated_user and ctx.authenticated_user.display_name:
player_name = ctx.authenticated_user.display_name
room.add_player(ctx.player_id, player_name, ctx.websocket, ctx.auth_user_id)
ctx.current_room = room
await ctx.websocket.send_json({
"type": "room_joined",
"room_code": room.code,
"player_id": ctx.player_id,
"authenticated": ctx.authenticated_user is not None,
})
await room.broadcast({
"type": "player_joined",
"players": room.player_list(),
})
async def handle_get_cpu_profiles(data: dict, ctx: ConnectionContext, **kw) -> None:
if not ctx.current_room:
return
await ctx.websocket.send_json({
"type": "cpu_profiles",
"profiles": get_all_profiles(),
})
async def handle_add_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can add CPU players"})
return
if len(ctx.current_room.players) >= 6:
await ctx.websocket.send_json({"type": "error", "message": "Room is full"})
return
cpu_id = f"cpu_{uuid.uuid4().hex[:8]}"
profile_name = data.get("profile_name")
cpu_player = ctx.current_room.add_cpu_player(cpu_id, profile_name)
if not cpu_player:
await ctx.websocket.send_json({"type": "error", "message": "CPU profile not available"})
return
await ctx.current_room.broadcast({
"type": "player_joined",
"players": ctx.current_room.player_list(),
})
async def handle_remove_cpu(data: dict, ctx: ConnectionContext, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
return
cpu_players = ctx.current_room.get_cpu_players()
if cpu_players:
ctx.current_room.remove_player(cpu_players[-1].id)
await ctx.current_room.broadcast({
"type": "player_joined",
"players": ctx.current_room.player_list(),
})
# ---------------------------------------------------------------------------
# Game lifecycle handlers
# ---------------------------------------------------------------------------
async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can start the game"})
return
if len(ctx.current_room.players) < 2:
await ctx.websocket.send_json({"type": "error", "message": "Need at least 2 players"})
return
num_decks = max(1, min(3, data.get("decks", 1)))
num_rounds = max(1, min(18, data.get("rounds", 1)))
options = GameOptions.from_client_data(data)
async with ctx.current_room.game_lock:
ctx.current_room.game.start_game(num_decks, num_rounds, options)
game_logger = get_logger()
if game_logger:
ctx.current_room.game_log_id = game_logger.log_game_start(
room_code=ctx.current_room.code,
num_players=len(ctx.current_room.players),
options=options,
)
# CPU players do their initial flips immediately
if options.initial_flips > 0:
for cpu in ctx.current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips(options.initial_flips)
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
# Send game started to all human players
for pid, player in ctx.current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = ctx.current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "game_started",
"game_state": game_state,
})
await check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
positions = data.get("positions", [])
async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
# ---------------------------------------------------------------------------
# Turn action handlers
# ---------------------------------------------------------------------------
async def handle_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room:
return
source = data.get("source", "deck")
async with ctx.current_room.game_lock:
discard_before_draw = ctx.current_room.game.discard_top()
card = ctx.current_room.game.draw_card(ctx.player_id, source)
if card:
player = ctx.current_room.game.get_player(ctx.player_id)
reason = f"took {discard_before_draw.rank.value} from discard" if source == "discard" else "drew from deck"
log_human_action(
ctx.current_room, player,
"take_discard" if source == "discard" else "draw_deck",
card=card, reason=reason,
)
await ctx.websocket.send_json({
"type": "card_drawn",
"card": card.to_dict(),
"source": source,
})
await broadcast_game_state(ctx.current_room)
async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
position = data.get("position", 0)
async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card
player = ctx.current_room.game.get_player(ctx.player_id)
old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None
discarded = ctx.current_room.game.swap_card(ctx.player_id, position)
if discarded:
if drawn_card and player:
old_rank = old_card.rank.value if old_card else "?"
log_human_action(
ctx.current_room, player, "swap",
card=drawn_card, position=position,
reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}",
)
await broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0)
await check_and_run_cpu_turn(ctx.current_room)
async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
drawn_card = ctx.current_room.game.drawn_card
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.discard_drawn(ctx.player_id):
if drawn_card and player:
log_human_action(
ctx.current_room, player, "discard",
card=drawn_card,
reason=f"discarded {drawn_card.rank.value}",
)
await broadcast_game_state(ctx.current_room)
if ctx.current_room.game.flip_on_discard:
player = ctx.current_room.game.get_player(ctx.player_id)
has_face_down = player and any(not c.face_up for c in player.cards)
if has_face_down:
await ctx.websocket.send_json({
"type": "can_flip",
"optional": ctx.current_room.game.flip_is_optional,
})
else:
await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room)
else:
logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn")
await check_and_run_cpu_turn(ctx.current_room)
async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
if ctx.current_room.game.cancel_discard_draw(ctx.player_id):
await broadcast_game_state(ctx.current_room)
async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
position = data.get("position", 0)
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
ctx.current_room.game.flip_and_end_turn(ctx.player_id, position)
if player and 0 <= position < len(player.cards):
flipped_card = player.cards[position]
log_human_action(
ctx.current_room, player, "flip",
card=flipped_card, position=position,
reason=f"flipped card at position {position}",
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.skip_flip_and_end_turn(ctx.player_id):
log_human_action(
ctx.current_room, player, "skip_flip",
reason="skipped optional flip (endgame mode)",
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
position = data.get("position", 0)
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.flip_card_as_action(ctx.player_id, position):
if player and 0 <= position < len(player.cards):
flipped_card = player.cards[position]
log_human_action(
ctx.current_room, player, "flip_as_action",
card=flipped_card, position=position,
reason=f"used flip-as-action to reveal position {position}",
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
async with ctx.current_room.game_lock:
player = ctx.current_room.game.get_player(ctx.player_id)
if ctx.current_room.game.knock_early(ctx.player_id):
if player:
face_down_count = sum(1 for c in player.cards if not c.face_up)
log_human_action(
ctx.current_room, player, "knock_early",
reason=f"knocked early, revealing {face_down_count} hidden cards",
)
await broadcast_game_state(ctx.current_room)
await check_and_run_cpu_turn(ctx.current_room)
async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_game_state, check_and_run_cpu_turn, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
return
async with ctx.current_room.game_lock:
if ctx.current_room.game.start_next_round():
for cpu in ctx.current_room.get_cpu_players():
positions = GolfAI.choose_initial_flips()
ctx.current_room.game.flip_initial_cards(cpu.id, positions)
for pid, player in ctx.current_room.players.items():
if player.websocket and not player.is_cpu:
game_state = ctx.current_room.game.get_state(pid)
await player.websocket.send_json({
"type": "round_started",
"game_state": game_state,
})
await check_and_run_cpu_turn(ctx.current_room)
else:
await broadcast_game_state(ctx.current_room)
# ---------------------------------------------------------------------------
# Leave / End handlers
# ---------------------------------------------------------------------------
async def handle_leave_room(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
if ctx.current_room:
await handle_player_leave(ctx.current_room, ctx.player_id)
ctx.current_room = None
async def handle_leave_game(data: dict, ctx: ConnectionContext, *, handle_player_leave, **kw) -> None:
if ctx.current_room:
await handle_player_leave(ctx.current_room, ctx.player_id)
ctx.current_room = None
async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, cleanup_room_profiles, **kw) -> None:
if not ctx.current_room:
return
room_player = ctx.current_room.get_player(ctx.player_id)
if not room_player or not room_player.is_host:
await ctx.websocket.send_json({"type": "error", "message": "Only the host can end the game"})
return
await ctx.current_room.broadcast({
"type": "game_ended",
"reason": "Host ended the game",
})
room_code = ctx.current_room.code
for cpu in list(ctx.current_room.get_cpu_players()):
ctx.current_room.remove_player(cpu.id)
cleanup_room_profiles(room_code)
room_manager.remove_room(room_code)
ctx.current_room = None
# ---------------------------------------------------------------------------
# Handler dispatch table
# ---------------------------------------------------------------------------
HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
"get_cpu_profiles": handle_get_cpu_profiles,
"add_cpu": handle_add_cpu,
"remove_cpu": handle_remove_cpu,
"start_game": handle_start_game,
"flip_initial": handle_flip_initial,
"draw": handle_draw,
"swap": handle_swap,
"discard": handle_discard,
"cancel_draw": handle_cancel_draw,
"flip_card": handle_flip_card,
"skip_flip": handle_skip_flip,
"flip_as_action": handle_flip_as_action,
"knock_early": handle_knock_early,
"next_round": handle_next_round,
"leave_room": handle_leave_room,
"leave_game": handle_leave_game,
"end_game": handle_end_game,
}