- 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>
569 lines
19 KiB
Python
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()
|