"""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 config import config 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 config.INVITE_ONLY and not ctx.authenticated_user: await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"}) return 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 # Use authenticated username as player name player_name = ctx.authenticated_user.username if ctx.authenticated_user else data.get("player_name", "Player") 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: if config.INVITE_ONLY and not ctx.authenticated_user: await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to play"}) return room_code = data.get("room_code", "").upper() # Use authenticated username as player name player_name = ctx.authenticated_user.username if ctx.authenticated_user else 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 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, }) 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) 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) 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) 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") 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) 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) 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) 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) 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, }) 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 # Cancel any running CPU turn task so the game ends immediately if ctx.current_room.cpu_turn_task: ctx.current_room.cpu_turn_task.cancel() try: await ctx.current_room.cpu_turn_task except (asyncio.CancelledError, Exception): pass ctx.current_room.cpu_turn_task = None 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 # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Matchmaking handlers # --------------------------------------------------------------------------- async def handle_queue_join(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, rating_service=None, **kw) -> None: if not matchmaking_service: await ctx.websocket.send_json({"type": "error", "message": "Matchmaking not available"}) return if not ctx.authenticated_user: await ctx.websocket.send_json({"type": "error", "message": "You must be logged in to find a game"}) return # Get player's rating rating = 1500.0 if rating_service: try: player_rating = await rating_service.get_rating(ctx.auth_user_id) rating = player_rating.rating except Exception: pass status = await matchmaking_service.join_queue( user_id=ctx.auth_user_id, username=ctx.authenticated_user.username, rating=rating, websocket=ctx.websocket, connection_id=ctx.connection_id, ) await ctx.websocket.send_json({ "type": "queue_joined", **status, }) async def handle_queue_leave(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None: if not matchmaking_service or not ctx.auth_user_id: return removed = await matchmaking_service.leave_queue(ctx.auth_user_id) await ctx.websocket.send_json({ "type": "queue_left", "was_queued": removed, }) async def handle_queue_status(data: dict, ctx: ConnectionContext, *, matchmaking_service=None, **kw) -> None: if not matchmaking_service or not ctx.auth_user_id: await ctx.websocket.send_json({"type": "queue_status", "in_queue": False}) return status = await matchmaking_service.get_queue_status(ctx.auth_user_id) await ctx.websocket.send_json({ "type": "queue_status", **status, }) 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, "queue_join": handle_queue_join, "queue_leave": handle_queue_leave, "queue_status": handle_queue_status, }