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>
265 lines
8.4 KiB
Python
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),
|
|
}
|