mantimon-tcg/backend/app/services/game_service.py
Cal Corum f512c7b2b3 Refactor to dependency injection pattern - no monkey patching
- ConnectionManager: Add redis_factory constructor parameter
- GameService: Add engine_factory constructor parameter
- AuthHandler: New class replacing standalone functions with
  token_verifier and conn_manager injection
- Update all tests to use constructor DI instead of patch()
- Update CLAUDE.md with factory injection patterns
- Update services README with new patterns
- Add socketio README documenting AuthHandler and events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:54:57 -06:00

569 lines
19 KiB
Python

"""Game service for orchestrating game lifecycle in Mantimon TCG.
This service is the bridge between WebSocket communication and the core
GameEngine. It handles:
- Game creation with deck loading and validation
- Action execution with persistence
- Game state retrieval with visibility filtering
- Game lifecycle (join, resign, end)
IMPORTANT: This service is stateless. All game-specific configuration
(RulesConfig) is stored in the GameState itself, not in this service.
Rules come from the frontend request at game creation time.
Architecture:
WebSocket Layer -> GameService -> GameEngine + GameStateManager
-> DeckService + CardService
Example:
from app.services.game_service import GameService, game_service
# Create a new game (rules from frontend)
result = await game_service.create_game(
player1_id=user1.id,
player2_id=user2.id,
deck1_id=deck1.id,
deck2_id=deck2.id,
rules_config=rules_from_request, # Frontend provides this
)
# Execute an action (uses rules stored in game state)
result = await game_service.execute_action(
game_id=game.id,
player_id=user1.id,
action=AttackAction(attack_index=0),
)
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID
from app.core.engine import ActionResult, GameEngine
from app.core.enums import GameEndReason
from app.core.models.actions import Action, ResignAction
from app.core.models.game_state import GameState
from app.core.rng import create_rng
from app.core.visibility import VisibleGameState, get_visible_state
from app.services.card_service import CardService, get_card_service
from app.services.game_state_manager import GameStateManager, game_state_manager
logger = logging.getLogger(__name__)
# Type alias for engine factory - takes GameState, returns GameEngine
EngineFactory = Callable[[GameState], GameEngine]
# =============================================================================
# Exceptions
# =============================================================================
class GameServiceError(Exception):
"""Base exception for GameService errors."""
pass
class GameNotFoundError(GameServiceError):
"""Raised when a game cannot be found."""
def __init__(self, game_id: str) -> None:
self.game_id = game_id
super().__init__(f"Game not found: {game_id}")
class NotPlayerTurnError(GameServiceError):
"""Raised when a player tries to act out of turn."""
def __init__(self, game_id: str, player_id: str, current_player_id: str) -> None:
self.game_id = game_id
self.player_id = player_id
self.current_player_id = current_player_id
super().__init__(
f"Not player's turn: {player_id} tried to act, but it's {current_player_id}'s turn"
)
class InvalidActionError(GameServiceError):
"""Raised when an action is invalid."""
def __init__(self, game_id: str, player_id: str, reason: str) -> None:
self.game_id = game_id
self.player_id = player_id
self.reason = reason
super().__init__(f"Invalid action in game {game_id}: {reason}")
class PlayerNotInGameError(GameServiceError):
"""Raised when a player is not a participant in the game."""
def __init__(self, game_id: str, player_id: str) -> None:
self.game_id = game_id
self.player_id = player_id
super().__init__(f"Player {player_id} is not in game {game_id}")
class GameAlreadyEndedError(GameServiceError):
"""Raised when trying to act on a game that has already ended."""
def __init__(self, game_id: str) -> None:
self.game_id = game_id
super().__init__(f"Game {game_id} has already ended")
# =============================================================================
# Result Types
# =============================================================================
@dataclass
class GameActionResult:
"""Result of executing a game action.
Attributes:
success: Whether the action succeeded.
game_id: The game ID.
action_type: The type of action executed.
message: Description of what happened.
state_changes: Dict of state changes for client updates.
game_over: Whether the game ended as a result.
winner_id: Winner's player ID if game ended with a winner.
end_reason: Reason the game ended, if applicable.
"""
success: bool
game_id: str
action_type: str
message: str = ""
state_changes: dict[str, Any] = field(default_factory=dict)
game_over: bool = False
winner_id: str | None = None
end_reason: GameEndReason | None = None
@dataclass
class GameJoinResult:
"""Result of joining a game.
Attributes:
success: Whether the join succeeded.
game_id: The game ID.
player_id: The joining player's ID.
visible_state: The game state visible to the player.
is_your_turn: Whether it's this player's turn.
message: Additional information or error message.
"""
success: bool
game_id: str
player_id: str
visible_state: VisibleGameState | None = None
is_your_turn: bool = False
message: str = ""
# =============================================================================
# GameService
# =============================================================================
class GameService:
"""Service for orchestrating game lifecycle operations.
This service coordinates between the WebSocket layer and the core
GameEngine, handling persistence and state management.
IMPORTANT: This service is STATELESS regarding game rules.
- Rules are stored in each GameState (set at creation time)
- The GameEngine is instantiated per-operation with the game's rules
- No RulesConfig is stored in this service
Attributes:
_state_manager: GameStateManager for persistence.
_card_service: CardService for card definitions.
_engine_factory: Factory function to create GameEngine instances.
"""
def __init__(
self,
state_manager: GameStateManager | None = None,
card_service: CardService | None = None,
engine_factory: EngineFactory | None = None,
) -> None:
"""Initialize the GameService.
Note: No GameEngine or RulesConfig here - those are per-game,
not per-service. The engine is created as needed using the
rules stored in each game's state.
Args:
state_manager: GameStateManager instance. Uses global if not provided.
card_service: CardService instance. Uses global if not provided.
engine_factory: Optional factory for creating GameEngine instances.
If not provided, uses the default _default_engine_factory method.
Useful for testing with mock engines.
"""
self._state_manager = state_manager or game_state_manager
self._card_service = card_service or get_card_service()
self._engine_factory = engine_factory or self._default_engine_factory
def _default_engine_factory(self, game: GameState) -> GameEngine:
"""Default factory for creating a GameEngine from game state.
The engine is created on-demand using the rules stored in the
game state. This ensures each game uses its own configuration.
For deterministic replay support, we derive a unique seed per action
by combining the game's base seed with the action count. This ensures:
- Same game + same action sequence = identical RNG results
- Each action gets a unique but reproducible random sequence
Args:
game: The game state containing the rules to use.
Returns:
A GameEngine configured with the game's rules and RNG.
"""
if game.rng_seed is not None:
# Derive unique seed per action for deterministic replay
# Action count ensures each action gets different but reproducible RNG
action_count = len(game.action_log)
action_seed = game.rng_seed + action_count
rng = create_rng(seed=action_seed)
else:
# No seed - use cryptographically secure RNG
rng = create_rng()
return GameEngine(rules=game.rules, rng=rng)
# =========================================================================
# Game State Access
# =========================================================================
async def get_game_state(self, game_id: str) -> GameState:
"""Get the full game state.
Args:
game_id: The game ID.
Returns:
The GameState.
Raises:
GameNotFoundError: If game doesn't exist.
"""
state = await self._state_manager.load_state(game_id)
if state is None:
raise GameNotFoundError(game_id)
return state
async def get_player_view(
self,
game_id: str,
player_id: str,
) -> VisibleGameState:
"""Get the game state filtered for a specific player's view.
This applies visibility rules to hide opponent's hidden information
(hand, deck, prizes).
Args:
game_id: The game ID.
player_id: The player to get the view for.
Returns:
VisibleGameState with appropriate filtering.
Raises:
GameNotFoundError: If game doesn't exist.
PlayerNotInGameError: If player is not in the game.
"""
state = await self.get_game_state(game_id)
if player_id not in state.players:
raise PlayerNotInGameError(game_id, player_id)
return get_visible_state(state, player_id)
async def is_player_turn(self, game_id: str, player_id: str) -> bool:
"""Check if it's the specified player's turn.
Args:
game_id: The game ID.
player_id: The player to check.
Returns:
True if it's the player's turn.
Raises:
GameNotFoundError: If game doesn't exist.
"""
state = await self.get_game_state(game_id)
return state.current_player_id == player_id
async def game_exists(self, game_id: str) -> bool:
"""Check if a game exists.
Args:
game_id: The game ID.
Returns:
True if the game exists in cache or database.
"""
return await self._state_manager.cache_exists(game_id)
# =========================================================================
# Game Lifecycle
# =========================================================================
async def join_game(
self,
game_id: str,
player_id: str,
last_event_id: str | None = None,
) -> GameJoinResult:
"""Join or rejoin a game session.
Loads the game state and returns the player's visible view.
Used when a player connects or reconnects to a game.
Args:
game_id: The game to join.
player_id: The joining player's ID.
last_event_id: Last event ID for reconnection replay (future use).
Returns:
GameJoinResult with the visible state.
"""
try:
state = await self.get_game_state(game_id)
except GameNotFoundError:
return GameJoinResult(
success=False,
game_id=game_id,
player_id=player_id,
message="Game not found",
)
if player_id not in state.players:
return GameJoinResult(
success=False,
game_id=game_id,
player_id=player_id,
message="You are not a participant in this game",
)
# Check if game already ended
if state.winner_id is not None or state.end_reason is not None:
visible = get_visible_state(state, player_id)
return GameJoinResult(
success=True,
game_id=game_id,
player_id=player_id,
visible_state=visible,
is_your_turn=False,
message="Game has ended",
)
visible = get_visible_state(state, player_id)
logger.info(f"Player {player_id} joined game {game_id}")
return GameJoinResult(
success=True,
game_id=game_id,
player_id=player_id,
visible_state=visible,
is_your_turn=state.current_player_id == player_id,
)
async def execute_action(
self,
game_id: str,
player_id: str,
action: Action,
) -> GameActionResult:
"""Execute a player action in the game.
Validates the action, executes it through GameEngine, and
persists the updated state. The GameEngine is created using
the rules stored in the game state.
Args:
game_id: The game ID.
player_id: The acting player's ID.
action: The action to execute.
Returns:
GameActionResult with success status and state changes.
Raises:
GameNotFoundError: If game doesn't exist.
PlayerNotInGameError: If player is not in the game.
GameAlreadyEndedError: If game has already ended.
NotPlayerTurnError: If it's not the player's turn.
InvalidActionError: If the action is invalid.
"""
# Load game state
state = await self.get_game_state(game_id)
# Validate player is in game
if player_id not in state.players:
raise PlayerNotInGameError(game_id, player_id)
# Check game hasn't ended
if state.winner_id is not None or state.end_reason is not None:
raise GameAlreadyEndedError(game_id)
# Check it's player's turn (unless resignation, which can happen anytime)
if not isinstance(action, ResignAction) and state.current_player_id != player_id:
raise NotPlayerTurnError(game_id, player_id, state.current_player_id)
# Create engine with this game's rules via factory
engine = self._engine_factory(state)
# Execute the action
result: ActionResult = await engine.execute_action(state, player_id, action)
if not result.success:
raise InvalidActionError(game_id, player_id, result.message)
# Save state to cache (fast path)
await self._state_manager.save_to_cache(state)
# Check if turn ended - persist to DB at turn boundaries
# TODO: Implement turn boundary detection for DB persistence
# Build response
action_result = GameActionResult(
success=True,
game_id=game_id,
action_type=action.type,
message=result.message,
state_changes={
"changes": result.state_changes,
},
)
# Check for game over
if result.win_result is not None:
action_result.game_over = True
action_result.winner_id = result.win_result.winner_id
action_result.end_reason = result.win_result.end_reason
# Persist final state to DB
await self._state_manager.persist_to_db(state)
logger.info(
f"Game {game_id} ended: winner={result.win_result.winner_id}, "
f"reason={result.win_result.end_reason}"
)
logger.debug(f"Action executed: game={game_id}, player={player_id}, type={action.type}")
return action_result
async def resign_game(
self,
game_id: str,
player_id: str,
) -> GameActionResult:
"""Resign from a game.
Convenience method that executes a ResignAction.
Args:
game_id: The game ID.
player_id: The resigning player's ID.
Returns:
GameActionResult indicating game over.
"""
return await self.execute_action(
game_id=game_id,
player_id=player_id,
action=ResignAction(),
)
async def end_game(
self,
game_id: str,
winner_id: str | None,
end_reason: GameEndReason,
) -> None:
"""Forcibly end a game (e.g., due to timeout or disconnection).
This should be called by the timeout system or when a player
disconnects without reconnecting within the grace period.
Args:
game_id: The game ID.
winner_id: The winner's player ID, or None for a draw.
end_reason: Why the game ended.
Raises:
GameNotFoundError: If game doesn't exist.
"""
state = await self.get_game_state(game_id)
# Set winner and end reason
state.winner_id = winner_id
state.end_reason = end_reason
# Persist to both cache and DB
await self._state_manager.save_to_cache(state)
await self._state_manager.persist_to_db(state)
logger.info(f"Game {game_id} forcibly ended: winner={winner_id}, reason={end_reason}")
# =========================================================================
# Game Creation (Skeleton - Full implementation in GS-002)
# =========================================================================
async def create_game(
self,
player1_id: str | UUID,
player2_id: str | UUID,
deck1_id: str | UUID | None = None,
deck2_id: str | UUID | None = None,
# Rules come from the frontend request - this is required, not optional
# Defaulting to None here only for the skeleton; GS-002 will make it required
) -> None:
"""Create a new game between two players.
This is a skeleton that will be fully implemented in GS-002.
IMPORTANT: rules_config will be a required parameter - it comes
from the frontend request, not from server-side defaults.
Args:
player1_id: First player's ID.
player2_id: Second player's ID.
deck1_id: First player's deck ID.
deck2_id: Second player's deck ID.
Raises:
NotImplementedError: Until GS-002 is complete.
"""
# TODO (GS-002): Full implementation with:
# - rules_config: RulesConfig parameter (required, from frontend)
# - Load decks via DeckService
# - Load card registry from CardService
# - Convert to CardInstances with unique IDs
# - Create GameState with the provided rules_config
# - Persist to Redis and Postgres
raise NotImplementedError(
"Game creation not yet implemented - see GS-002. "
"Rules will come from frontend request, not server defaults."
)
# Global singleton instance
# Note: This is safe because GameService is stateless regarding game rules.
# Each game's rules are stored in its GameState, not in this service.
game_service = GameService()