"""Deck service for Mantimon TCG. This module provides business logic for deck management. It uses the DeckRepository protocol for data access and pure validation functions. The service layer handles: - Deck slot limits (free vs premium users) - Deck validation with optional ownership checking - Starter deck creation The backend is stateless - deck rules come from the request via DeckConfig. Example: from app.core.config import DeckConfig from app.services.card_service import CardService from app.services.deck_service import DeckService from app.repositories.postgres import PostgresDeckRepository, PostgresCollectionRepository # Create dependencies card_service = CardService() card_service.load_all() # Create repositories deck_repo = PostgresDeckRepository(db_session) collection_repo = PostgresCollectionRepository(db_session) # Create service service = DeckService(deck_repo, card_service, collection_repo) # Create a deck - rules provided by caller deck = await service.create_deck( user_id=user_id, name="My Deck", cards={"a1-001-bulbasaur": 4, ...}, energy_cards={"grass": 14, "colorless": 6}, deck_config=DeckConfig(), # Rules from frontend max_decks=5, # From user.max_decks ) """ import logging from uuid import UUID from sqlalchemy.exc import IntegrityError from app.core.config import DeckConfig from app.core.models.card import CardDefinition from app.repositories.protocols import ( UNSET, CollectionRepository, DeckEntry, DeckRepository, ) from app.services.card_service import CardService from app.services.deck_validator import ValidationResult, validate_deck class DeckLimitExceededError(Exception): """Raised when user tries to create more decks than allowed.""" pass class StarterAlreadySelectedError(Exception): """Raised when user tries to select a starter deck they already have.""" pass class DeckNotFoundError(Exception): """Raised when deck is not found or not owned by user.""" pass class DeckService: """Service for deck business logic. Uses repository pattern for data access, enabling: - Easy unit testing with mock repositories - Multiple storage backends - Offline fork support The backend is stateless - deck rules come from the request via DeckConfig. Attributes: _deck_repo: The deck repository implementation. _collection_repo: The collection repository (for ownership checks). _card_service: The card service for card lookups. """ def __init__( self, deck_repository: DeckRepository, card_service: CardService, collection_repository: CollectionRepository | None = None, ) -> None: """Initialize with dependencies. Args: deck_repository: Implementation of DeckRepository protocol. card_service: Card service for looking up card definitions. collection_repository: Implementation of CollectionRepository protocol. Required for ownership validation in campaign mode. """ self._deck_repo = deck_repository self._card_service = card_service self._collection_repo = collection_repository async def create_deck( self, user_id: UUID, name: str, cards: dict[str, int], energy_cards: dict[str, int], deck_config: DeckConfig, max_decks: int, validate_ownership: bool = True, is_starter: bool = False, starter_type: str | None = None, description: str | None = None, ) -> DeckEntry: """Create a new deck. Validates the deck and stores validation results. Invalid decks CAN be saved (with errors) to support work-in-progress decks. Args: user_id: The user's UUID. name: Display name for the deck. cards: Card ID to quantity mapping. energy_cards: Energy type to quantity mapping. deck_config: Deck rules from the caller (frontend provides this). max_decks: Maximum decks allowed (from user.max_decks). validate_ownership: If True, checks card ownership (campaign mode). is_starter: Whether this is a starter deck. starter_type: Type of starter deck if applicable. description: Optional deck description. Returns: The created DeckEntry. Raises: DeckLimitExceededError: If user has reached deck limit. Example: deck = await service.create_deck( user_id=user_id, name="Grass Power", cards={"a1-001-bulbasaur": 4, ...}, energy_cards={"grass": 14, "colorless": 6}, deck_config=DeckConfig(), max_decks=5, ) """ # Check deck limit current_count = await self._deck_repo.count_by_user(user_id) if current_count >= max_decks: raise DeckLimitExceededError( f"Deck limit reached ({current_count}/{max_decks}). " "Upgrade to premium for unlimited decks." ) # Validate deck validation = await self._validate_deck_internal( cards, energy_cards, deck_config, user_id if validate_ownership else None ) return await self._deck_repo.create( user_id=user_id, name=name, cards=cards, energy_cards=energy_cards, is_valid=validation.is_valid, validation_errors=validation.errors if validation.errors else None, is_starter=is_starter, starter_type=starter_type, description=description, ) async def update_deck( self, user_id: UUID, deck_id: UUID, deck_config: DeckConfig, name: str | None = None, cards: dict[str, int] | None = None, energy_cards: dict[str, int] | None = None, validate_ownership: bool = True, description: str | None = UNSET, # type: ignore[assignment] ) -> DeckEntry: """Update an existing deck. Re-validates if cards or energy_cards change. Args: user_id: The user's UUID (for ownership verification). deck_id: The deck's UUID. deck_config: Deck rules from the caller (frontend provides this). name: New name (optional, None keeps existing). cards: New card composition (optional, None keeps existing). energy_cards: New energy composition (optional, None keeps existing). validate_ownership: If True, checks card ownership (campaign mode). description: New description (UNSET=keep, None=clear, str=set). Returns: The updated DeckEntry. Raises: DeckNotFoundError: If deck not found or not owned by user. """ # Verify ownership deck = await self._deck_repo.get_user_deck(user_id, deck_id) if deck is None: raise DeckNotFoundError(f"Deck {deck_id} not found or not owned by user") # Determine if we need to re-validate needs_revalidation = cards is not None or energy_cards is not None # Use existing values if not provided final_cards = cards if cards is not None else deck.cards final_energy = energy_cards if energy_cards is not None else deck.energy_cards # Re-validate if needed is_valid = deck.is_valid validation_errors = deck.validation_errors if needs_revalidation: validation = await self._validate_deck_internal( final_cards, final_energy, deck_config, user_id if validate_ownership else None, ) is_valid = validation.is_valid validation_errors = validation.errors if validation.errors else None result = await self._deck_repo.update( deck_id=deck_id, name=name, cards=cards, energy_cards=energy_cards, is_valid=is_valid, validation_errors=validation_errors, description=description, ) if result is None: raise DeckNotFoundError(f"Deck {deck_id} not found") return result async def delete_deck(self, user_id: UUID, deck_id: UUID) -> bool: """Delete a deck. Args: user_id: The user's UUID (for ownership verification). deck_id: The deck's UUID. Returns: True if deleted. Raises: DeckNotFoundError: If deck not found or not owned by user. """ # Verify ownership deck = await self._deck_repo.get_user_deck(user_id, deck_id) if deck is None: raise DeckNotFoundError(f"Deck {deck_id} not found or not owned by user") return await self._deck_repo.delete(deck_id) async def get_deck(self, user_id: UUID, deck_id: UUID) -> DeckEntry: """Get a deck owned by user. Args: user_id: The user's UUID. deck_id: The deck's UUID. Returns: The DeckEntry. Raises: DeckNotFoundError: If deck not found or not owned by user. """ deck = await self._deck_repo.get_user_deck(user_id, deck_id) if deck is None: raise DeckNotFoundError(f"Deck {deck_id} not found or not owned by user") return deck async def get_user_decks(self, user_id: UUID) -> list[DeckEntry]: """Get all decks for a user. Args: user_id: The user's UUID. Returns: List of all user's decks. """ return await self._deck_repo.get_by_user(user_id) async def can_create_deck(self, user_id: UUID, max_decks: int) -> bool: """Check if user can create another deck. Args: user_id: The user's UUID. max_decks: Maximum decks allowed (from user.max_decks). Returns: True if user can create another deck. """ current_count = await self._deck_repo.count_by_user(user_id) return current_count < max_decks async def get_deck_count(self, user_id: UUID) -> int: """Get number of decks user has. Args: user_id: The user's UUID. Returns: Number of decks. """ return await self._deck_repo.count_by_user(user_id) async def _validate_deck_internal( self, cards: dict[str, int], energy_cards: dict[str, int], deck_config: DeckConfig, user_id: UUID | None = None, ) -> ValidationResult: """Internal method to validate a deck composition. Args: cards: Card ID to quantity mapping. energy_cards: Energy type to quantity mapping. deck_config: Deck rules from the caller. user_id: If provided, validates card ownership (campaign mode). Pass None for freeplay mode. Returns: ValidationResult with is_valid and errors. """ owned_cards: dict[str, int] | None = None if user_id is not None and self._collection_repo is not None: # Get user's collection for ownership validation collection = await self._collection_repo.get_all(user_id) owned_cards = {entry.card_definition_id: entry.quantity for entry in collection} return validate_deck( cards=cards, energy_cards=energy_cards, deck_config=deck_config, card_lookup=self._card_service.get_card, owned_cards=owned_cards, ) async def get_deck_for_game(self, user_id: UUID, deck_id: UUID) -> list[CardDefinition]: """Expand a deck to a list of CardDefinitions for game use. Used by GameEngine to create a game from a deck. Args: user_id: The user's UUID. deck_id: The deck's UUID. Returns: List of CardDefinition objects for each card in the deck (duplicates included based on quantity). Raises: DeckNotFoundError: If deck not found or not owned by user. ValueError: If deck contains invalid card IDs that cannot be resolved. """ deck = await self.get_deck(user_id, deck_id) cards: list[CardDefinition] = [] invalid_card_ids: list[str] = [] for card_id, quantity in deck.cards.items(): card_def = self._card_service.get_card(card_id) if card_def is not None: cards.extend([card_def] * quantity) else: invalid_card_ids.append(card_id) if invalid_card_ids: logger = logging.getLogger(__name__) logger.warning( f"Deck {deck_id} contains invalid card IDs: {invalid_card_ids[:5]}" + (f" (and {len(invalid_card_ids) - 5} more)" if len(invalid_card_ids) > 5 else "") ) raise ValueError( f"Deck contains {len(invalid_card_ids)} invalid card(s) that cannot be found: " f"{', '.join(invalid_card_ids[:5])}" + (f" (and {len(invalid_card_ids) - 5} more)" if len(invalid_card_ids) > 5 else "") ) return cards async def has_starter_deck(self, user_id: UUID) -> tuple[bool, str | None]: """Check if user has a starter deck. Args: user_id: The user's UUID. Returns: Tuple of (has_starter, starter_type). """ return await self._deck_repo.has_starter(user_id) async def create_starter_deck( self, user_id: UUID, starter_type: str, deck_config: DeckConfig, max_decks: int, ) -> DeckEntry: """Create a starter deck for a user. .. deprecated:: Use select_and_grant_starter_deck() instead, which atomically creates the deck AND grants cards with race condition protection. This creates the deck but does NOT grant the cards to collection. Args: user_id: The user's UUID. starter_type: Type of starter deck (grass, fire, water, etc.). deck_config: Deck rules from the caller (frontend provides this). max_decks: Maximum decks allowed (from user.max_decks). Returns: The created starter DeckEntry. Raises: ValueError: If starter_type is invalid. DeckLimitExceededError: If user has reached deck limit. """ from app.data.starter_decks import STARTER_TYPES, get_starter_deck if starter_type not in STARTER_TYPES: raise ValueError( f"Invalid starter type: {starter_type}. " f"Must be one of: {', '.join(STARTER_TYPES)}" ) starter = get_starter_deck(starter_type) return await self.create_deck( user_id=user_id, name=starter["name"], cards=starter["cards"], energy_cards=starter["energy_cards"], deck_config=deck_config, max_decks=max_decks, validate_ownership=False, # Starter decks skip ownership check is_starter=True, starter_type=starter_type, description=starter["description"], ) async def select_and_grant_starter_deck( self, user_id: UUID, starter_type: str, deck_config: DeckConfig, max_decks: int, ) -> DeckEntry: """Select a starter deck, granting cards and creating the deck atomically. This is the preferred method for starter deck selection. It handles: - Validation of starter type - Race condition protection via database unique constraint - Creating the deck and granting cards together The database has a partial unique index on (user_id) WHERE is_starter=true, which prevents duplicate starter decks even under concurrent requests. Args: user_id: The user's UUID. starter_type: Type of starter deck (grass, fire, water, etc.). deck_config: Deck rules from the caller (frontend provides this). max_decks: Maximum decks allowed (from user.max_decks). Returns: The created starter DeckEntry. Raises: ValueError: If starter_type is invalid. StarterAlreadySelectedError: If user already has a starter deck. DeckLimitExceededError: If user has reached deck limit. """ from app.data.starter_decks import STARTER_TYPES, get_starter_deck from app.db.models.collection import CardSource logger = logging.getLogger(__name__) # Validate starter type early if starter_type not in STARTER_TYPES: raise ValueError( f"Invalid starter type: {starter_type}. " f"Must be one of: {', '.join(STARTER_TYPES)}" ) # Fast path: check if user already has a starter has_starter, existing_type = await self.has_starter_deck(user_id) if has_starter: raise StarterAlreadySelectedError(f"Starter deck already selected: {existing_type}") starter = get_starter_deck(starter_type) # Try to create the deck first - protected by unique constraint # If two concurrent requests try this, one will fail with IntegrityError try: deck = await self.create_deck( user_id=user_id, name=starter["name"], cards=starter["cards"], energy_cards=starter["energy_cards"], deck_config=deck_config, max_decks=max_decks, validate_ownership=False, is_starter=True, starter_type=starter_type, description=starter["description"], ) except IntegrityError as e: # Unique constraint violation - another request beat us logger.info(f"Starter deck creation race condition for user {user_id}: {e}") raise StarterAlreadySelectedError( "Starter deck already selected (concurrent request)" ) from None # Deck created successfully - now grant the cards # This should never fail for valid starter types if self._collection_repo is not None: for card_id, quantity in starter["cards"].items(): await self._collection_repo.upsert( user_id=user_id, card_definition_id=card_id, quantity_delta=quantity, source=CardSource.STARTER, ) for energy_type, quantity in starter["energy_cards"].items(): # Energy cards are stored as "energy-{type}" in collection await self._collection_repo.upsert( user_id=user_id, card_definition_id=f"energy-{energy_type}", quantity_delta=quantity, source=CardSource.STARTER, ) return deck