diff --git a/server/handlers.py b/server/handlers.py index a1a6f43..3c9c76f 100644 --- a/server/handlers.py +++ b/server/handlers.py @@ -229,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam "game_state": game_state, }) - await check_and_run_cpu_turn(ctx.current_room) + 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: @@ -240,7 +240,7 @@ async def handle_flip_initial(data: dict, ctx: ConnectionContext, *, broadcast_g 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) + check_and_run_cpu_turn(ctx.current_room) # --------------------------------------------------------------------------- @@ -297,7 +297,7 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat await broadcast_game_state(ctx.current_room) await asyncio.sleep(1.0) - await check_and_run_cpu_turn(ctx.current_room) + 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: @@ -329,12 +329,12 @@ async def handle_discard(data: dict, ctx: ConnectionContext, *, broadcast_game_s }) else: await asyncio.sleep(0.5) - await check_and_run_cpu_turn(ctx.current_room) + 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) + check_and_run_cpu_turn(ctx.current_room) async def handle_cancel_draw(data: dict, ctx: ConnectionContext, *, broadcast_game_state, **kw) -> None: @@ -364,7 +364,7 @@ async def handle_flip_card(data: dict, ctx: ConnectionContext, *, broadcast_game ) await broadcast_game_state(ctx.current_room) - await check_and_run_cpu_turn(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: @@ -380,7 +380,7 @@ async def handle_skip_flip(data: dict, ctx: ConnectionContext, *, broadcast_game ) await broadcast_game_state(ctx.current_room) - await check_and_run_cpu_turn(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: @@ -400,7 +400,7 @@ async def handle_flip_as_action(data: dict, ctx: ConnectionContext, *, broadcast ) await broadcast_game_state(ctx.current_room) - await check_and_run_cpu_turn(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: @@ -418,7 +418,7 @@ async def handle_knock_early(data: dict, ctx: ConnectionContext, *, broadcast_ga ) await broadcast_game_state(ctx.current_room) - await check_and_run_cpu_turn(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: @@ -443,7 +443,7 @@ async def handle_next_round(data: dict, ctx: ConnectionContext, *, broadcast_gam "game_state": game_state, }) - await check_and_run_cpu_turn(ctx.current_room) + check_and_run_cpu_turn(ctx.current_room) else: await broadcast_game_state(ctx.current_room) diff --git a/server/main.py b/server/main.py index 31feffb..2e8e821 100644 --- a/server/main.py +++ b/server/main.py @@ -705,30 +705,13 @@ 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. +def check_and_run_cpu_turn(room: Room): + """Check if current player is CPU and start their turn as a background task. - 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. + The CPU turn chain runs as a fire-and-forget asyncio.Task stored on + room.cpu_turn_task. This allows the WebSocket message loop to remain + responsive so that end_game/leave messages can cancel the task immediately. """ - # 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 @@ -740,17 +723,41 @@ async def _run_cpu_chain(room: Room): if not room_player or not room_player.is_cpu: return - # Brief pause before CPU starts - animations are faster now - await asyncio.sleep(0.25) + task = asyncio.create_task(_run_cpu_chain(room)) + room.cpu_turn_task = task - # Run CPU turn - async def broadcast_cb(): - await broadcast_game_state(room) + def _on_done(t: asyncio.Task): + # Clear the reference when the task finishes (success, cancel, or error) + if room.cpu_turn_task is t: + room.cpu_turn_task = None + if not t.cancelled() and t.exception(): + logger.error(f"CPU turn task error in room {room.code}: {t.exception()}") - await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) + task.add_done_callback(_on_done) - # Check if next player is also CPU (chain CPU turns) - await _run_cpu_chain(room) + +async def _run_cpu_chain(room: Room): + """Run consecutive CPU turns until a human player's turn or game ends.""" + while True: + if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN): + return + + current = room.game.current_player() + if not current: + return + + room_player = room.get_player(current.id) + if not room_player or not room_player.is_cpu: + return + + # Brief pause before CPU starts - animations are faster now + await asyncio.sleep(0.25) + + # Run CPU turn + async def broadcast_cb(): + await broadcast_game_state(room) + + await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) async def handle_player_leave(room: Room, player_id: str):