"""Collection service for Mantimon TCG. This module provides business logic for user card collections. It uses the CollectionRepository protocol for data access, enabling easy testing and multiple storage backends (PostgreSQL, SQLite, local files). The service layer handles: - Card ID validation via CardService - Starter deck granting logic - Collection statistics Example: from app.services.card_service import CardService from app.services.collection_service import CollectionService from app.repositories.postgres import PostgresCollectionRepository # Create dependencies card_service = CardService() card_service.load_all() # Create repository and service repo = PostgresCollectionRepository(db_session) service = CollectionService(repo, card_service) # Add cards to collection entry = await service.add_cards( user_id, "a1-001-bulbasaur", quantity=2, source=CardSource.BOOSTER ) """ from uuid import UUID from app.db.models.collection import CardSource from app.repositories.protocols import CollectionEntry, CollectionRepository from app.services.card_service import CardService class CollectionService: """Service for card collection business logic. Uses repository pattern for data access, enabling: - Easy unit testing with mock repositories - Multiple storage backends - Offline fork support Attributes: _repo: The collection repository implementation. _card_service: The card service for card validation. """ def __init__(self, repository: CollectionRepository, card_service: CardService) -> None: """Initialize with dependencies. Args: repository: Implementation of CollectionRepository protocol. card_service: Card service for validating card IDs. """ self._repo = repository self._card_service = card_service async def get_collection(self, user_id: UUID) -> list[CollectionEntry]: """Get all cards in a user's collection. Args: user_id: The user's UUID. Returns: List of CollectionEntry for the user. Example: collection = await service.get_collection(user_id) for entry in collection: print(f"{entry.card_definition_id}: {entry.quantity}") """ return await self._repo.get_all(user_id) async def get_card_quantity(self, user_id: UUID, card_definition_id: str) -> int: """Get quantity of a specific card owned by user. Args: user_id: The user's UUID. card_definition_id: Card ID to check. Returns: Number of copies owned (0 if not owned). """ return await self._repo.get_quantity(user_id, card_definition_id) async def get_collection_entry( self, user_id: UUID, card_definition_id: str ) -> CollectionEntry | None: """Get a specific collection entry. Args: user_id: The user's UUID. card_definition_id: Card ID to look up. Returns: CollectionEntry if exists, None otherwise. """ return await self._repo.get_by_card(user_id, card_definition_id) async def add_cards( self, user_id: UUID, card_definition_id: str, quantity: int, source: CardSource, ) -> CollectionEntry: """Add cards to a user's collection. Validates that the card ID exists before adding. Uses upsert pattern: creates new entry if card not owned, or increments quantity if already owned. Args: user_id: The user's UUID. card_definition_id: Card ID to add. quantity: Number of copies to add. source: How the cards were obtained. Returns: The created or updated CollectionEntry. Raises: ValueError: If card_definition_id doesn't exist. Example: entry = await service.add_cards( user_id, "a1-001-bulbasaur", quantity=2, source=CardSource.BOOSTER ) """ # Validate card exists if self._card_service.get_card(card_definition_id) is None: raise ValueError(f"Invalid card ID: {card_definition_id}") return await self._repo.upsert(user_id, card_definition_id, quantity, source) async def remove_cards( self, user_id: UUID, card_definition_id: str, quantity: int, ) -> CollectionEntry | None: """Remove cards from a user's collection. Decrements quantity. If quantity reaches 0, deletes the entry. Args: user_id: The user's UUID. card_definition_id: Card ID to remove. quantity: Number of copies to remove. Returns: Updated CollectionEntry, or None if card not owned or all copies were removed. """ return await self._repo.decrement(user_id, card_definition_id, quantity) async def has_cards( self, user_id: UUID, card_requirements: dict[str, int], ) -> bool: """Check if user owns at least the required quantity of each card. Args: user_id: The user's UUID. card_requirements: Mapping of card IDs to required quantities. Returns: True if user owns enough of all cards, False otherwise. Example: can_build = await service.has_cards( user_id, {"a1-001-bulbasaur": 4, "a1-002-ivysaur": 2} ) """ for card_id, required_qty in card_requirements.items(): owned_qty = await self._repo.get_quantity(user_id, card_id) if owned_qty < required_qty: return False return True async def get_owned_cards_dict(self, user_id: UUID) -> dict[str, int]: """Get user's collection as a card_id -> quantity mapping. Useful for deck validation in campaign mode. Args: user_id: The user's UUID. Returns: Dictionary mapping card IDs to quantities. Example: owned = await service.get_owned_cards_dict(user_id) # Pass to validate_deck function result = validate_deck(cards, energy, deck_config, card_lookup, owned_cards=owned) """ collection = await self._repo.get_all(user_id) return {entry.card_definition_id: entry.quantity for entry in collection} async def grant_starter_deck( self, user_id: UUID, starter_type: str, ) -> list[CollectionEntry]: """Grant all cards from a starter deck to user's collection. .. deprecated:: Use DeckService.select_and_grant_starter_deck() instead, which atomically creates the starter deck AND grants the cards with race condition protection. Uses CardSource.STARTER for all granted cards. Args: user_id: The user's UUID. starter_type: Type of starter deck (grass, fire, water, etc.). Returns: List of CollectionEntry created/updated. Raises: ValueError: If starter_type is invalid. Example: entries = await service.grant_starter_deck(user_id, "grass") """ # Import here to avoid circular dependency 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_deck = get_starter_deck(starter_type) entries: list[CollectionEntry] = [] # Add all cards from the deck for card_id, quantity in starter_deck["cards"].items(): entry = await self.add_cards(user_id, card_id, quantity, CardSource.STARTER) entries.append(entry) return entries async def get_collection_stats(self, user_id: UUID) -> dict[str, int]: """Get aggregate statistics for user's collection. Args: user_id: The user's UUID. Returns: Dictionary with total_unique_cards and total_card_count. """ collection = await self._repo.get_all(user_id) return { "total_unique_cards": len(collection), "total_card_count": sum(entry.quantity for entry in collection), }