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,
|
"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)
|
||||||
|
|
||||||
|
|||||||
@ -705,30 +705,40 @@ 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.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||||
if room.cpu_turn_task is not None:
|
|
||||||
await _run_cpu_chain(room)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Outermost call: wrap the chain in a cancellable task
|
current = room.game.current_player()
|
||||||
room.cpu_turn_task = asyncio.create_task(_run_cpu_chain(room))
|
if not current:
|
||||||
try:
|
return
|
||||||
await room.cpu_turn_task
|
|
||||||
except asyncio.CancelledError:
|
room_player = room.get_player(current.id)
|
||||||
pass
|
if not room_player or not room_player.is_cpu:
|
||||||
finally:
|
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
|
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):
|
async def _run_cpu_chain(room: Room):
|
||||||
"""Run consecutive CPU turns until a human player's turn or game ends."""
|
"""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):
|
if room.game.phase not in (GamePhase.PLAYING, GamePhase.FINAL_TURN):
|
||||||
return
|
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)
|
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):
|
async def handle_player_leave(room: Room, player_id: str):
|
||||||
"""Handle a player leaving a room."""
|
"""Handle a player leaving a room."""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user