"""Turn timeout management for Mantimon TCG. This module manages turn time limits using a polling-based approach with Redis for state storage. It handles: - Starting and canceling turn timers - Checking for expired timers - Sending percentage-based warnings (e.g., 50%, 25% remaining) - Granting grace periods on reconnection Key Patterns: turn_timeout:{game_id} - Hash with timeout data: - player_id: The player whose turn it is - deadline: Unix timestamp when the turn expires - timeout_seconds: Original timeout duration (for % calculation) - warnings_sent: JSON array of thresholds already sent Design Decisions: - Polling approach (not keyspace notifications) for simplicity and reliability. A background task polls check_expired_timers() periodically. - Warnings are percentage-based (configurable, default 50% and 25%) so they scale with different timeout durations. - Grace period on reconnect extends the deadline without resetting the warning state. Example: from app.services.turn_timeout_service import turn_timeout_service # Start a timer when a turn begins await turn_timeout_service.start_turn_timer( game_id="game-123", player_id="player-1", timeout_seconds=180, warning_thresholds=[50, 25], ) # Check for warnings to send warning = await turn_timeout_service.get_pending_warning("game-123") if warning: # Send warning to player await turn_timeout_service.mark_warning_sent("game-123", warning.threshold) # On reconnect, grant grace period await turn_timeout_service.extend_timer("game-123", extension_seconds=15) # Background task polls for expired timers expired = await turn_timeout_service.check_expired_timers() for game_id in expired: # Handle timeout (auto-pass or loss) """ import json import logging from collections.abc import AsyncIterator, Callable from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING from app.db.redis import get_redis if TYPE_CHECKING: from redis.asyncio import Redis logger = logging.getLogger(__name__) # Type alias for redis factory RedisFactory = Callable[[], AsyncIterator["Redis"]] # Redis key patterns TURN_TIMEOUT_PREFIX = "turn_timeout:" # Default TTL for timeout keys (cleanup buffer beyond actual timeout) DEFAULT_KEY_TTL_BUFFER = 300 # 5 minutes beyond deadline @dataclass class TurnTimeoutInfo: """Information about a turn timeout. Attributes: game_id: The game ID. player_id: The player whose turn is timing out. deadline: Unix timestamp when the turn expires. timeout_seconds: Original timeout duration. remaining_seconds: Seconds remaining until timeout. warnings_sent: List of warning thresholds already sent. warning_thresholds: Configured warning thresholds. """ game_id: str player_id: str deadline: float timeout_seconds: int remaining_seconds: int warnings_sent: list[int] warning_thresholds: list[int] @property def is_expired(self) -> bool: """Check if the timeout has expired.""" return self.remaining_seconds <= 0 @property def percent_remaining(self) -> float: """Get the percentage of time remaining.""" if self.timeout_seconds <= 0: return 0.0 return (self.remaining_seconds / self.timeout_seconds) * 100 @dataclass class PendingWarning: """Information about a warning that should be sent. Attributes: game_id: The game ID. player_id: The player to warn. threshold: The warning threshold percentage (e.g., 50, 25). remaining_seconds: Seconds remaining when warning triggered. """ game_id: str player_id: str threshold: int remaining_seconds: int class TurnTimeoutService: """Service for managing turn timeouts. Uses Redis for persistent storage of timeout state. Designed for a polling model where a background task periodically checks for expired timers and pending warnings. Attributes: _get_redis: Factory for Redis connections. """ def __init__( self, redis_factory: RedisFactory | None = None, ) -> None: """Initialize the TurnTimeoutService. Args: redis_factory: Optional factory for Redis connections. If not provided, uses the default get_redis from app.db.redis. Useful for testing. """ self._get_redis = redis_factory if redis_factory is not None else get_redis def _timeout_key(self, game_id: str) -> str: """Generate Redis key for a game's timeout data.""" return f"{TURN_TIMEOUT_PREFIX}{game_id}" # ========================================================================= # Timer Lifecycle # ========================================================================= async def start_turn_timer( self, game_id: str, player_id: str, timeout_seconds: int, warning_thresholds: list[int] | None = None, ) -> TurnTimeoutInfo: """Start a turn timer for a game. Creates or replaces the timeout data for the game. Resets warnings since this is a new turn. Args: game_id: The game ID. player_id: The player whose turn is starting. timeout_seconds: Seconds until the turn times out. warning_thresholds: Percentage thresholds for warnings (e.g., [50, 25]). Defaults to [50, 25] if not provided. Returns: TurnTimeoutInfo with the new timer state. Example: info = await service.start_turn_timer("game-123", "player-1", 180) print(f"Turn expires at {info.deadline}") """ if warning_thresholds is None: warning_thresholds = [50, 25] # Sort thresholds descending so we warn at highest % first warning_thresholds = sorted(warning_thresholds, reverse=True) deadline = datetime.now(UTC).timestamp() + timeout_seconds async with self._get_redis() as redis: key = self._timeout_key(game_id) await redis.hset( key, mapping={ "player_id": player_id, "deadline": str(deadline), "timeout_seconds": str(timeout_seconds), "warnings_sent": json.dumps([]), "warning_thresholds": json.dumps(warning_thresholds), }, ) # Set TTL slightly beyond deadline for auto-cleanup ttl = timeout_seconds + DEFAULT_KEY_TTL_BUFFER await redis.expire(key, ttl) logger.debug( f"Started turn timer: game={game_id}, player={player_id}, " f"timeout={timeout_seconds}s, thresholds={warning_thresholds}" ) return TurnTimeoutInfo( game_id=game_id, player_id=player_id, deadline=deadline, timeout_seconds=timeout_seconds, remaining_seconds=timeout_seconds, warnings_sent=[], warning_thresholds=warning_thresholds, ) async def cancel_timer(self, game_id: str) -> bool: """Cancel a turn timer. Removes the timeout data for the game. Use when a turn ends normally or the game ends. Args: game_id: The game ID. Returns: True if a timer was canceled, False if none existed. Example: canceled = await service.cancel_timer("game-123") """ async with self._get_redis() as redis: key = self._timeout_key(game_id) deleted = await redis.delete(key) if deleted: logger.debug(f"Canceled turn timer: game={game_id}") return deleted > 0 async def extend_timer( self, game_id: str, extension_seconds: int, ) -> TurnTimeoutInfo | None: """Extend a turn timer (e.g., on reconnection). Adds time to the existing deadline without resetting warnings. The extension is capped so the total time doesn't exceed the original timeout. Args: game_id: The game ID. extension_seconds: Seconds to add to the deadline. Returns: Updated TurnTimeoutInfo, or None if no timer exists. Example: # Grant 15 seconds grace on reconnect info = await service.extend_timer("game-123", 15) """ info = await self.get_timeout_info(game_id) if info is None: return None # Calculate new deadline, capped at original timeout now = datetime.now(UTC).timestamp() new_deadline = info.deadline + extension_seconds max_deadline = now + info.timeout_seconds if new_deadline > max_deadline: new_deadline = max_deadline async with self._get_redis() as redis: key = self._timeout_key(game_id) await redis.hset(key, "deadline", str(new_deadline)) # Refresh TTL remaining = int(new_deadline - now) + DEFAULT_KEY_TTL_BUFFER if remaining > 0: await redis.expire(key, remaining) new_remaining = max(0, int(new_deadline - now)) logger.debug( f"Extended turn timer: game={game_id}, " f"added={extension_seconds}s, remaining={new_remaining}s" ) return TurnTimeoutInfo( game_id=info.game_id, player_id=info.player_id, deadline=new_deadline, timeout_seconds=info.timeout_seconds, remaining_seconds=new_remaining, warnings_sent=info.warnings_sent, warning_thresholds=info.warning_thresholds, ) # ========================================================================= # Query Methods # ========================================================================= async def get_timeout_info(self, game_id: str) -> TurnTimeoutInfo | None: """Get timeout information for a game. Args: game_id: The game ID. Returns: TurnTimeoutInfo if a timer exists, None otherwise. Example: info = await service.get_timeout_info("game-123") if info: print(f"{info.remaining_seconds}s remaining") """ async with self._get_redis() as redis: key = self._timeout_key(game_id) data = await redis.hgetall(key) if not data: return None # Validate required fields player_id = data.get("player_id") deadline_str = data.get("deadline") timeout_str = data.get("timeout_seconds") if not player_id or not deadline_str or not timeout_str: logger.warning(f"Corrupted timeout data for game {game_id}") return None try: deadline = float(deadline_str) timeout_seconds = int(timeout_str) warnings_sent = json.loads(data.get("warnings_sent", "[]")) warning_thresholds = json.loads(data.get("warning_thresholds", "[50, 25]")) except (ValueError, json.JSONDecodeError) as e: logger.warning(f"Invalid timeout data for game {game_id}: {e}") return None now = datetime.now(UTC).timestamp() remaining = max(0, int(deadline - now)) return TurnTimeoutInfo( game_id=game_id, player_id=player_id, deadline=deadline, timeout_seconds=timeout_seconds, remaining_seconds=remaining, warnings_sent=warnings_sent, warning_thresholds=warning_thresholds, ) async def get_remaining_time(self, game_id: str) -> int | None: """Get remaining seconds for a game's turn timer. Convenience method that returns just the remaining time. Args: game_id: The game ID. Returns: Seconds remaining, or None if no timer exists. Example: remaining = await service.get_remaining_time("game-123") if remaining is not None and remaining < 30: # Show low time warning in UI """ info = await self.get_timeout_info(game_id) return info.remaining_seconds if info else None # ========================================================================= # Warning Management # ========================================================================= async def get_pending_warning(self, game_id: str) -> PendingWarning | None: """Check if a warning should be sent for a game. Returns the highest-priority unsent warning if the time remaining has dropped below a warning threshold. Args: game_id: The game ID. Returns: PendingWarning if one should be sent, None otherwise. Example: warning = await service.get_pending_warning("game-123") if warning: await send_warning_to_player(warning.player_id, warning.remaining_seconds) await service.mark_warning_sent("game-123", warning.threshold) """ info = await self.get_timeout_info(game_id) if info is None or info.is_expired: return None percent_remaining = info.percent_remaining # Check each threshold (sorted descending) for threshold in info.warning_thresholds: if threshold in info.warnings_sent: continue # Already sent this warning if percent_remaining <= threshold: return PendingWarning( game_id=game_id, player_id=info.player_id, threshold=threshold, remaining_seconds=info.remaining_seconds, ) return None async def mark_warning_sent(self, game_id: str, threshold: int) -> bool: """Mark a warning threshold as sent. Call this after successfully sending a warning to the player to prevent duplicate warnings. Args: game_id: The game ID. threshold: The warning threshold that was sent. Returns: True if marked successfully, False if timer doesn't exist. Example: await service.mark_warning_sent("game-123", 50) """ info = await self.get_timeout_info(game_id) if info is None: return False if threshold in info.warnings_sent: return True # Already marked warnings_sent = info.warnings_sent + [threshold] async with self._get_redis() as redis: key = self._timeout_key(game_id) await redis.hset(key, "warnings_sent", json.dumps(warnings_sent)) logger.debug(f"Marked warning sent: game={game_id}, threshold={threshold}%") return True # ========================================================================= # Expiration Checking # ========================================================================= async def check_expired_timers(self) -> list[TurnTimeoutInfo]: """Check for expired turn timers. Scans all active timers and returns those that have expired. The caller is responsible for handling the timeouts (auto-pass, loss declaration, etc.). Returns: List of TurnTimeoutInfo for expired timers. Example: # In background task, poll every 5 seconds expired = await service.check_expired_timers() for info in expired: await handle_turn_timeout(info.game_id, info.player_id) await service.cancel_timer(info.game_id) """ expired: list[TurnTimeoutInfo] = [] async with self._get_redis() as redis: # Scan for all timeout keys async for key in redis.scan_iter(match=f"{TURN_TIMEOUT_PREFIX}*"): game_id = key[len(TURN_TIMEOUT_PREFIX) :] info = await self.get_timeout_info(game_id) if info is not None and info.is_expired: expired.append(info) logger.debug(f"Found expired timer: game={game_id}") return expired async def get_all_pending_warnings(self) -> list[PendingWarning]: """Get all pending warnings across all games. Scans all active timers and returns warnings that should be sent. Useful for a background task that handles warnings in batch. Returns: List of PendingWarning for all games needing warnings. Example: # In background task warnings = await service.get_all_pending_warnings() for warning in warnings: await send_warning(warning) await service.mark_warning_sent(warning.game_id, warning.threshold) """ warnings: list[PendingWarning] = [] async with self._get_redis() as redis: async for key in redis.scan_iter(match=f"{TURN_TIMEOUT_PREFIX}*"): game_id = key[len(TURN_TIMEOUT_PREFIX) :] warning = await self.get_pending_warning(game_id) if warning is not None: warnings.append(warning) return warnings # ========================================================================= # Utility Methods # ========================================================================= async def get_active_timer_count(self) -> int: """Get the number of active turn timers. Useful for monitoring and admin dashboards. Returns: Number of active timers. """ count = 0 async with self._get_redis() as redis: async for _ in redis.scan_iter(match=f"{TURN_TIMEOUT_PREFIX}*"): count += 1 return count async def has_active_timer(self, game_id: str) -> bool: """Check if a game has an active turn timer. Args: game_id: The game ID. Returns: True if a timer exists (even if expired), False otherwise. """ async with self._get_redis() as redis: key = self._timeout_key(game_id) return await redis.exists(key) > 0 # Global singleton instance turn_timeout_service = TurnTimeoutService()