Make CPU turn chain fire-and-forget so end game is instant

The CPU turn chain was awaited inline inside game_lock, blocking the
WebSocket message loop. The end_game message couldn't be processed
until all CPU turns finished. Now check_and_run_cpu_turn launches a
background task and returns immediately, keeping the message loop
responsive. The end_game and leave handlers cancel the task on demand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken 2026-02-22 17:26:58 -05:00
parent de3495635b
commit 3261e6ee26
2 changed files with 47 additions and 40 deletions

View File

@ -229,7 +229,7 @@ async def handle_start_game(data: dict, ctx: ConnectionContext, *, broadcast_gam
"game_state": game_state, "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: 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: async with ctx.current_room.game_lock:
if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions): if ctx.current_room.game.flip_initial_cards(ctx.player_id, positions):
await broadcast_game_state(ctx.current_room) 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 broadcast_game_state(ctx.current_room)
await asyncio.sleep(1.0) 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: 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: else:
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
logger.debug("Player discarded, waiting 0.5s before CPU turn") logger.debug("Player discarded, waiting 0.5s before CPU turn")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
logger.debug("Post-discard delay complete, checking for CPU turn") 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: 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 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: 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 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: 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 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: 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 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: 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, "game_state": game_state,
}) })
await check_and_run_cpu_turn(ctx.current_room) check_and_run_cpu_turn(ctx.current_room)
else: else:
await broadcast_game_state(ctx.current_room) await broadcast_game_state(ctx.current_room)

View File

@ -705,30 +705,13 @@ async def broadcast_game_state(room: Room):
}) })
async def check_and_run_cpu_turn(room: Room): 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 start their turn as a background task.
If this is the outermost call (no CPU task running), creates a single The CPU turn chain runs as a fire-and-forget asyncio.Task stored on
asyncio.Task that runs the entire chain of consecutive CPU turns. room.cpu_turn_task. This allows the WebSocket message loop to remain
This allows the task to be cancelled cleanly when the game ends. 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): if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
return return
@ -740,17 +723,41 @@ async def _run_cpu_chain(room: Room):
if not room_player or not room_player.is_cpu: if not room_player or not room_player.is_cpu:
return return
# Brief pause before CPU starts - animations are faster now task = asyncio.create_task(_run_cpu_chain(room))
await asyncio.sleep(0.25) room.cpu_turn_task = task
# Run CPU turn def _on_done(t: asyncio.Task):
async def broadcast_cb(): # Clear the reference when the task finishes (success, cancel, or error)
await broadcast_game_state(room) 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): async def handle_player_leave(room: Room, player_id: str):