mantimon-tcg/backend/app/services/turn_timeout_service.py
Cal Corum f452e69999 Complete Phase 4 implementation files
- TurnTimeoutService with percentage-based warnings (35 tests)
- ConnectionManager enhancements for spectators and reconnection
- GameService with timer integration, spectator support, handle_timeout
- GameNamespace with spectate/leave_spectate handlers, reconnection
- WebSocket message schemas for spectator events
- WinConditionsConfig additions for turn timer thresholds
- 83 GameService tests, 37 ConnectionManager tests, 37 GameNamespace tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:03:43 -06:00

551 lines
18 KiB
Python

"""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()