diff --git a/server/ai.py b/server/ai.py index 22d5a12..b80f086 100644 --- a/server/ai.py +++ b/server/ai.py @@ -1934,7 +1934,11 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga async def process_cpu_turn( game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None ) -> None: - """Process a complete turn for a CPU player.""" + """Process a complete turn for a CPU player. + + May raise asyncio.CancelledError if the game is ended mid-turn. + The caller (check_and_run_cpu_turn) handles cancellation. + """ import asyncio from services.game_logger import get_logger diff --git a/server/handlers.py b/server/handlers.py index ff4f0e0..a1a6f43 100644 --- a/server/handlers.py +++ b/server/handlers.py @@ -473,6 +473,15 @@ async def handle_end_game(data: dict, ctx: ConnectionContext, *, room_manager, c 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", diff --git a/server/main.py b/server/main.py index 31d9dd7..31feffb 100644 --- a/server/main.py +++ b/server/main.py @@ -706,7 +706,29 @@ async def broadcast_game_state(room: Room): async def check_and_run_cpu_turn(room: Room): - """Check if current player is CPU and run their turn.""" + """Check if current player is CPU and run their turn. + + If this is the outermost call (no CPU task running), creates a single + asyncio.Task that runs the entire chain of consecutive CPU turns. + This allows the task to be cancelled cleanly when the game ends. + """ + # If already inside a CPU turn task, run directly (no nested tasks) + if room.cpu_turn_task is not None: + await _run_cpu_chain(room) + return + + # Outermost call: wrap the chain in a cancellable task + room.cpu_turn_task = asyncio.create_task(_run_cpu_chain(room)) + try: + await room.cpu_turn_task + except asyncio.CancelledError: + pass + finally: + room.cpu_turn_task = None + + +async def _run_cpu_chain(room: Room): + """Run consecutive CPU turns until a human player's turn or game ends.""" if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): return @@ -728,11 +750,20 @@ async def check_and_run_cpu_turn(room: Room): await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) # Check if next player is also CPU (chain CPU turns) - await check_and_run_cpu_turn(room) + await _run_cpu_chain(room) async def handle_player_leave(room: Room, player_id: str): """Handle a player leaving a room.""" + # Cancel any running CPU turn task before cleanup + if room.cpu_turn_task: + room.cpu_turn_task.cancel() + try: + await room.cpu_turn_task + except (asyncio.CancelledError, Exception): + pass + room.cpu_turn_task = None + room_code = room.code room_player = room.remove_player(player_id) diff --git a/server/room.py b/server/room.py index 1984f6c..57076cb 100644 --- a/server/room.py +++ b/server/room.py @@ -69,6 +69,7 @@ class Room: settings: dict = field(default_factory=lambda: {"decks": 1, "rounds": 1}) game_log_id: Optional[str] = None game_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + cpu_turn_task: Optional[asyncio.Task] = None def add_player( self,