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:
parent
de3495635b
commit
3261e6ee26
@ -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)
|
||||
|
||||
|
||||
@ -705,30 +705,40 @@ 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)
|
||||
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||
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:
|
||||
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
|
||||
|
||||
task = asyncio.create_task(_run_cpu_chain(room))
|
||||
room.cpu_turn_task = task
|
||||
|
||||
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()}")
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -749,9 +759,6 @@ async def _run_cpu_chain(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 _run_cpu_chain(room)
|
||||
|
||||
|
||||
async def handle_player_leave(room: Room, player_id: str):
|
||||
"""Handle a player leaving a room."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user