Auto-play a random legal card when the turn timeout expires

This commit is contained in:
2026-09-16 11:31:06 +00:00
parent 047f43fa21
commit 550bd3aefd
14 changed files with 285 additions and 8 deletions
+81 -7
View File
@@ -29,12 +29,19 @@ Mutations run under the per-game lock; after a successful move the new
state is saved to Redis and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers).
If a player does not move before the per-game ``turn_timeout``, the server
plays a random card (with a random legal capture when one is required) for
them, so a disconnected or idle player cannot stall the match. The timer is
re-armed by every client connection and state broadcast, and fires
immediately when a reconnect finds the deadline already past.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Dict, Optional
from kaya.core import WebSocket
@@ -43,7 +50,7 @@ from . import auth
from .app import app, game_store
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_FINISHED, PHASE_HAND_END, GameState
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
from .stats import save_match_result
Send = Callable[[Dict[str, Any]], Awaitable[None]]
@@ -81,6 +88,7 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
await ws.send_text(json.dumps(payload))
await send(_state_message(state, user.sub))
schedule_turn_timer(game_id, state)
async with game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
@@ -110,6 +118,7 @@ async def _forward(
state = await game_store.load(game_id)
if state is None:
return
schedule_turn_timer(game_id, state)
await send(_state_message(state, sub))
if state.phase == PHASE_FINISHED:
await send(
@@ -195,6 +204,76 @@ def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> Non
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
# --- auto-play on turn timeout ------------------------------------------------
# Running turn timers, keyed by (game_id, hand_number, turn, deadline), so a
# turn's timeout is scheduled only once even when several clients are
# connected. Including the deadline means a re-arm after a reconnect cannot
# duplicate a timer for a turn that was already auto-played.
_turn_timers: Dict[tuple, asyncio.Task] = {}
def schedule_turn_timer(game_id: str, state: GameState) -> None:
"""Auto-play a random legal card if the player on turn misses the
deadline. Fizzles if the turn already advanced."""
if state.phase != PHASE_PLAYING or not state.turn_deadline:
return
key = (game_id, state.hand_number, state.turn, state.turn_deadline)
if key in _turn_timers:
return
hand_number = state.hand_number
turn = state.turn
deadline_raw = state.turn_deadline
try:
deadline = datetime.fromisoformat(deadline_raw)
except ValueError:
return
async def _auto_play() -> None:
try:
delay = (deadline - datetime.now(timezone.utc)).total_seconds()
await asyncio.sleep(max(delay, 0))
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if (
state is None
or state.phase != PHASE_PLAYING
or state.hand_number != hand_number
or state.turn != turn
or state.turn_deadline != deadline_raw
):
# The turn moved on (or the game ended) without this
# timer firing: make sure the current turn is armed.
if state is not None:
schedule_turn_timer(game_id, state)
return
try:
engine.auto_play(state)
except GameError:
return
await _after_play(state, game_id)
finally:
_turn_timers.pop(key, None)
_turn_timers[key] = asyncio.create_task(_auto_play())
async def _after_play(state: GameState, game_id: str) -> None:
"""Persist a successful move and notify every connected player.
Callers must hold the per-game lock. Handles the two terminal
transitions: the match result is written to Postgres once, and a
hand-end summary schedules the auto-continue timeout.
"""
if state.phase == PHASE_FINISHED:
await save_match_result(state)
elif state.phase == PHASE_HAND_END:
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
await game_store.save(state)
await game_store.publish(game_id)
async def _handle_play(
send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None:
@@ -224,9 +303,4 @@ async def _handle_play(
await send(_error("invalid card code", code="illegal_move"))
return
if state.phase == PHASE_FINISHED:
await save_match_result(state)
elif state.phase == PHASE_HAND_END:
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
await game_store.save(state)
await game_store.publish(game_id)
await _after_play(state, game_id)