mantimon-tcg/backend/app/services/collection_service.py
Cal Corum 7d397a2e22 Fix medium priority issues from code review
UNSET sentinel pattern:
- Add UNSET sentinel in protocols.py for nullable field updates
- Fix inability to clear deck description (UNSET=keep, None=clear)
- Fix repository inability to clear validation_errors

Starter deck improvements:
- Remove unused has_starter_deck from CollectionService
- Add deprecation notes to old starter deck methods

Validation improvements:
- Add energy type validation in deck_validator.py
- Add energy type validation in deck schemas
- Add VALID_ENERGY_TYPES constant

Game loading fix:
- Fix get_deck_for_game silently skipping invalid cards
- Now raises ValueError with clear error message

Tests:
- Add TestEnergyTypeValidation test class
- Add TestGetDeckForGame test class
- Add tests for validate_energy_types utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:32:08 -06:00

265 lines
8.4 KiB
Python

"""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),
}