Three changes to fail fast instead of silently degrading: 1. GameService.create_game: Raise GameCreationError when energy card definition not found instead of logging warning and continuing. A deck with missing energy cards is fundamentally broken. 2. CardService.load_all: Collect all card file load failures and raise CardServiceLoadError at end with comprehensive error report. Prevents startup with partial card data that causes cryptic runtime errors. New exceptions: CardLoadError, CardServiceLoadError 3. GameStateManager.recover_active_games: Return RecoveryResult dataclass with recovered count, failed game IDs with error messages, and total. Enables proper monitoring and alerting for corrupted game state. Tests added for energy card error case. Existing tests updated for new RecoveryResult return type. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
868 lines
30 KiB
Python
868 lines
30 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),
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid as uuid_module
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
from uuid import UUID
|
|
|
|
if TYPE_CHECKING:
|
|
from app.services.deck_service import DeckService
|
|
|
|
from app.core.config import RulesConfig
|
|
from app.core.engine import ActionResult, GameCreationResult, GameEngine
|
|
from app.core.enums import GameEndReason
|
|
from app.core.models.actions import Action, ResignAction
|
|
from app.core.models.card import CardInstance
|
|
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.db.models.game import GameType
|
|
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 (for execute_action)
|
|
EngineFactory = Callable[[GameState], GameEngine]
|
|
|
|
# Type alias for creation factory - takes RulesConfig, returns GameEngine (for create_game)
|
|
CreationEngineFactory = Callable[[RulesConfig], 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")
|
|
|
|
|
|
class GameCreationError(GameServiceError):
|
|
"""Raised when game creation fails."""
|
|
|
|
def __init__(self, reason: str) -> None:
|
|
self.reason = reason
|
|
super().__init__(f"Failed to create game: {reason}")
|
|
|
|
|
|
class ForcedActionRequiredError(GameServiceError):
|
|
"""Raised when a forced action is required but a different action was attempted."""
|
|
|
|
def __init__(
|
|
self,
|
|
game_id: str,
|
|
player_id: str,
|
|
required_action_type: str,
|
|
attempted_action_type: str,
|
|
) -> None:
|
|
self.game_id = game_id
|
|
self.player_id = player_id
|
|
self.required_action_type = required_action_type
|
|
self.attempted_action_type = attempted_action_type
|
|
super().__init__(
|
|
f"Forced action required: {required_action_type}, "
|
|
f"but {attempted_action_type} was attempted"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Result Types
|
|
# =============================================================================
|
|
|
|
|
|
@dataclass
|
|
class PendingForcedAction:
|
|
"""Information about a pending forced action for the client.
|
|
|
|
Attributes:
|
|
player_id: The player who must take the action.
|
|
action_type: The type of action required.
|
|
reason: Human-readable explanation.
|
|
params: Additional parameters for the action.
|
|
"""
|
|
|
|
player_id: str
|
|
action_type: str
|
|
reason: str
|
|
params: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@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.
|
|
turn_changed: Whether the turn changed as a result of this action.
|
|
current_player_id: The current player after action execution.
|
|
pending_forced_action: If set, the next action must be this forced action.
|
|
"""
|
|
|
|
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
|
|
turn_changed: bool = False
|
|
current_player_id: str | None = None
|
|
pending_forced_action: PendingForcedAction | 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 = ""
|
|
|
|
|
|
@dataclass
|
|
class GameCreateResult:
|
|
"""Result of creating a new game.
|
|
|
|
Attributes:
|
|
success: Whether the game was created successfully.
|
|
game_id: The created game's ID (None if failed).
|
|
player1_view: Initial state visible to player 1.
|
|
player2_view: Initial state visible to player 2.
|
|
starting_player_id: Which player goes first.
|
|
message: Description or error message.
|
|
"""
|
|
|
|
success: bool
|
|
game_id: str | None = None
|
|
player1_view: VisibleGameState | None = None
|
|
player2_view: VisibleGameState | None = None
|
|
starting_player_id: str | None = None
|
|
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 for creating GameEngine for action execution.
|
|
_creation_engine_factory: Factory for creating GameEngine for game creation.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
state_manager: GameStateManager | None = None,
|
|
card_service: CardService | None = None,
|
|
engine_factory: EngineFactory | None = None,
|
|
creation_engine_factory: CreationEngineFactory | 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 for action
|
|
execution. Takes GameState, returns GameEngine. If not provided,
|
|
uses the default _default_engine_factory method.
|
|
creation_engine_factory: Optional factory for creating GameEngine for
|
|
game creation. Takes RulesConfig, returns GameEngine. If not
|
|
provided, uses the default _default_creation_engine_factory method.
|
|
"""
|
|
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
|
|
self._creation_engine_factory = (
|
|
creation_engine_factory or self._default_creation_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)
|
|
|
|
def _default_creation_engine_factory(self, rules: RulesConfig) -> GameEngine:
|
|
"""Default factory for creating a GameEngine for game creation.
|
|
|
|
Used when creating a new game. The engine is created with the
|
|
provided rules and a fresh RNG.
|
|
|
|
Args:
|
|
rules: The RulesConfig for the new game.
|
|
|
|
Returns:
|
|
A GameEngine configured with the rules and fresh RNG.
|
|
"""
|
|
return GameEngine(rules=rules, rng=create_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.
|
|
|
|
Handles forced actions: if there's a pending forced action (e.g.,
|
|
select new active after KO), only that action type from the
|
|
specified player is allowed.
|
|
|
|
Persists to database at turn boundaries for durability.
|
|
|
|
Args:
|
|
game_id: The game ID.
|
|
player_id: The acting player's ID.
|
|
action: The action to execute.
|
|
|
|
Returns:
|
|
GameActionResult with success status, state changes, and
|
|
any pending forced actions.
|
|
|
|
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.
|
|
ForcedActionRequiredError: If a forced action is required but
|
|
a different action was attempted.
|
|
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 for forced actions (except resignation, which is always allowed)
|
|
forced = state.get_current_forced_action()
|
|
if forced is not None and not isinstance(action, ResignAction):
|
|
# Only the specified player can act during a forced action
|
|
if player_id != forced.player_id:
|
|
raise NotPlayerTurnError(game_id, player_id, forced.player_id)
|
|
# Only the specified action type is allowed
|
|
if action.type != forced.action_type:
|
|
raise ForcedActionRequiredError(game_id, player_id, forced.action_type, action.type)
|
|
elif not isinstance(action, ResignAction) and state.current_player_id != player_id:
|
|
# Normal turn check (no forced action pending)
|
|
raise NotPlayerTurnError(game_id, player_id, state.current_player_id)
|
|
|
|
# Track turn state before action for boundary detection
|
|
turn_before = state.turn_number
|
|
player_before = 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)
|
|
|
|
# Detect turn change
|
|
turn_changed = state.turn_number != turn_before or state.current_player_id != player_before
|
|
|
|
# Save state to cache (fast path)
|
|
await self._state_manager.save_to_cache(state)
|
|
|
|
# Persist to DB at turn boundaries for durability
|
|
if turn_changed:
|
|
await self._state_manager.persist_to_db(state)
|
|
logger.debug(f"Turn boundary: persisted game {game_id} to DB")
|
|
|
|
# Build response
|
|
action_result = GameActionResult(
|
|
success=True,
|
|
game_id=game_id,
|
|
action_type=action.type,
|
|
message=result.message,
|
|
state_changes={
|
|
"changes": result.state_changes,
|
|
},
|
|
turn_changed=turn_changed,
|
|
current_player_id=state.current_player_id,
|
|
)
|
|
|
|
# Include pending forced action if any
|
|
next_forced = state.get_current_forced_action()
|
|
if next_forced is not None:
|
|
action_result.pending_forced_action = PendingForcedAction(
|
|
player_id=next_forced.player_id,
|
|
action_type=next_forced.action_type,
|
|
reason=next_forced.reason,
|
|
params=next_forced.params,
|
|
)
|
|
|
|
# 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
|
|
# =========================================================================
|
|
|
|
def _cards_to_instances(
|
|
self,
|
|
cards: list,
|
|
player_id: str,
|
|
prefix: str = "card",
|
|
) -> list[CardInstance]:
|
|
"""Convert CardDefinitions to CardInstances with unique IDs.
|
|
|
|
Args:
|
|
cards: List of CardDefinition objects.
|
|
player_id: The owning player's ID (used in instance ID).
|
|
prefix: Prefix for instance IDs (e.g., "main" or "energy").
|
|
|
|
Returns:
|
|
List of CardInstance objects with unique instance_ids.
|
|
"""
|
|
instances = []
|
|
for i, card_def in enumerate(cards):
|
|
instance = CardInstance(
|
|
instance_id=f"{player_id}-{prefix}-{i}-{uuid_module.uuid4().hex[:8]}",
|
|
definition_id=card_def.id,
|
|
)
|
|
instances.append(instance)
|
|
return instances
|
|
|
|
def _build_card_registry(
|
|
self,
|
|
*card_lists: list,
|
|
) -> dict[str, Any]:
|
|
"""Build a card registry from card definition lists.
|
|
|
|
Only includes cards that are actually in the decks, not all cards.
|
|
|
|
Args:
|
|
card_lists: Variable number of CardDefinition lists.
|
|
|
|
Returns:
|
|
Dict mapping definition_id -> CardDefinition.
|
|
"""
|
|
registry: dict[str, Any] = {}
|
|
for cards in card_lists:
|
|
for card_def in cards:
|
|
if card_def.id not in registry:
|
|
registry[card_def.id] = card_def
|
|
return registry
|
|
|
|
async def create_game(
|
|
self,
|
|
player1_id: UUID,
|
|
player2_id: UUID,
|
|
deck1_id: UUID,
|
|
deck2_id: UUID,
|
|
rules_config: RulesConfig,
|
|
deck_service: DeckService,
|
|
game_type: GameType = GameType.FREEPLAY,
|
|
game_id: str | None = None,
|
|
) -> GameCreateResult:
|
|
"""Create a new game between two players.
|
|
|
|
Loads decks via DeckService, converts cards to instances, initializes
|
|
game state via GameEngine, and persists to both Redis and Postgres.
|
|
|
|
IMPORTANT: rules_config is required and comes from the frontend
|
|
request, not from server-side defaults.
|
|
|
|
Args:
|
|
player1_id: First player's UUID.
|
|
player2_id: Second player's UUID.
|
|
deck1_id: First player's deck UUID.
|
|
deck2_id: Second player's deck UUID.
|
|
rules_config: Game rules from the frontend request.
|
|
deck_service: DeckService instance for loading decks (from API layer).
|
|
game_type: Type of game (freeplay, ranked, etc.). Defaults to FREEPLAY.
|
|
game_id: Optional game ID. Auto-generated if not provided.
|
|
|
|
Returns:
|
|
GameCreateResult with game_id, initial player views, and starting player.
|
|
|
|
Raises:
|
|
GameCreationError: If deck loading or game creation fails.
|
|
"""
|
|
p1_str = str(player1_id)
|
|
p2_str = str(player2_id)
|
|
|
|
# Load decks via DeckService
|
|
try:
|
|
# get_deck_for_game returns list[CardDefinition] expanded for quantities
|
|
deck1_cards = await deck_service.get_deck_for_game(player1_id, deck1_id)
|
|
deck2_cards = await deck_service.get_deck_for_game(player2_id, deck2_id)
|
|
|
|
# get_deck returns DeckEntry with energy_cards dict
|
|
deck1_entry = await deck_service.get_deck(player1_id, deck1_id)
|
|
deck2_entry = await deck_service.get_deck(player2_id, deck2_id)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load decks: {e}")
|
|
raise GameCreationError(f"Failed to load decks: {e}") from e
|
|
|
|
# Expand energy cards to CardDefinitions
|
|
energy1_cards = []
|
|
for energy_type, qty in deck1_entry.energy_cards.items():
|
|
# Energy card IDs follow pattern: energy-basic-{type}
|
|
energy_id = f"energy-basic-{energy_type}"
|
|
energy_def = self._card_service.get_card(energy_id)
|
|
if energy_def is None:
|
|
raise GameCreationError(
|
|
f"Energy card definition not found: {energy_id}. "
|
|
"Check that card data is loaded correctly."
|
|
)
|
|
energy1_cards.extend([energy_def] * qty)
|
|
|
|
energy2_cards = []
|
|
for energy_type, qty in deck2_entry.energy_cards.items():
|
|
energy_id = f"energy-basic-{energy_type}"
|
|
energy_def = self._card_service.get_card(energy_id)
|
|
if energy_def is None:
|
|
raise GameCreationError(
|
|
f"Energy card definition not found: {energy_id}. "
|
|
"Check that card data is loaded correctly."
|
|
)
|
|
energy2_cards.extend([energy_def] * qty)
|
|
|
|
# Convert CardDefinitions to CardInstances with unique IDs
|
|
deck1_instances = self._cards_to_instances(deck1_cards, p1_str, "main")
|
|
deck2_instances = self._cards_to_instances(deck2_cards, p2_str, "main")
|
|
energy1_instances = self._cards_to_instances(energy1_cards, p1_str, "energy")
|
|
energy2_instances = self._cards_to_instances(energy2_cards, p2_str, "energy")
|
|
|
|
# Build decks dict for GameEngine
|
|
decks = {
|
|
p1_str: deck1_instances,
|
|
p2_str: deck2_instances,
|
|
}
|
|
energy_decks = {
|
|
p1_str: energy1_instances,
|
|
p2_str: energy2_instances,
|
|
}
|
|
|
|
# Build card registry from only the cards in play
|
|
card_registry = self._build_card_registry(
|
|
deck1_cards, deck2_cards, energy1_cards, energy2_cards
|
|
)
|
|
|
|
# Create engine with the provided rules via factory (allows testing)
|
|
engine = self._creation_engine_factory(rules_config)
|
|
|
|
# Create the game via GameEngine
|
|
result: GameCreationResult = engine.create_game(
|
|
player_ids=[p1_str, p2_str],
|
|
decks=decks,
|
|
card_registry=card_registry,
|
|
energy_decks=energy_decks if energy1_instances or energy2_instances else None,
|
|
game_id=game_id,
|
|
)
|
|
|
|
if not result.success or result.game is None:
|
|
logger.error(f"GameEngine failed to create game: {result.message}")
|
|
raise GameCreationError(result.message)
|
|
|
|
game = result.game
|
|
|
|
# Persist to both cache and database
|
|
try:
|
|
await self._state_manager.save_to_cache(game)
|
|
await self._state_manager.persist_to_db(
|
|
game,
|
|
game_type=game_type,
|
|
player1_id=player1_id,
|
|
player2_id=player2_id,
|
|
rules_config=rules_config.model_dump() if rules_config else None,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to persist game state: {e}")
|
|
raise GameCreationError(f"Failed to persist game: {e}") from e
|
|
|
|
# Get player-visible views
|
|
player1_view = get_visible_state(game, p1_str)
|
|
player2_view = get_visible_state(game, p2_str)
|
|
|
|
logger.info(
|
|
f"Created game {game.game_id}: {p1_str} vs {p2_str}, "
|
|
f"starting player: {game.current_player_id}"
|
|
)
|
|
|
|
return GameCreateResult(
|
|
success=True,
|
|
game_id=game.game_id,
|
|
player1_view=player1_view,
|
|
player2_view=player2_view,
|
|
starting_player_id=game.current_player_id,
|
|
message="Game created successfully",
|
|
)
|
|
|
|
|
|
# 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()
|