- 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>
551 lines
18 KiB
Python
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()
|