"""Deck schemas for Mantimon TCG. This module defines Pydantic models for deck-related API requests and responses. Decks contain card compositions for gameplay. The backend is stateless - deck rules come from the request via DeckConfig. Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom). Example: deck = DeckResponse( id=uuid4(), name="Electric Storm", cards={"a1-094-pikachu": 4, "a1-095-raichu": 2}, energy_cards={"lightning": 14, "colorless": 6}, is_valid=True, validation_errors=None, is_starter=False, starter_type=None, created_at=datetime.now(UTC), updated_at=datetime.now(UTC) ) """ import re from datetime import datetime from uuid import UUID from pydantic import BaseModel, Field, field_validator from app.core.config import DeckConfig from app.core.enums import EnergyType # Card ID format: set-number-name (e.g., a1-001-bulbasaur, a1-094-pikachu-ex) CARD_ID_PATTERN = re.compile(r"^[a-z0-9]+-\d{3}-[a-z0-9-]+$") # Set of valid energy type names (lowercase values from EnergyType enum) VALID_ENERGY_TYPES: frozenset[str] = frozenset(e.value for e in EnergyType) def validate_card_quantities(cards: dict[str, int], field_name: str) -> dict[str, int]: """Validate card quantities are within valid range. Args: cards: Mapping of card IDs to quantities. field_name: Name of the field for error messages. Returns: The validated card dictionary. Raises: ValueError: If any quantity is invalid. """ for card_id, quantity in cards.items(): if quantity < 1: raise ValueError( f"{field_name}: quantity for '{card_id}' must be at least 1, got {quantity}" ) if quantity > 99: raise ValueError( f"{field_name}: quantity for '{card_id}' cannot exceed 99, got {quantity}" ) return cards def validate_energy_types(energy_cards: dict[str, int], field_name: str) -> dict[str, int]: """Validate energy type names are valid. Args: energy_cards: Mapping of energy type names to quantities. field_name: Name of the field for error messages. Returns: The validated energy card dictionary. Raises: ValueError: If any energy type is invalid. """ invalid_types = [et for et in energy_cards if et not in VALID_ENERGY_TYPES] if invalid_types: display_types = invalid_types[:5] more = len(invalid_types) - 5 error_msg = f"{field_name}: invalid energy types: {', '.join(display_types)}" if more > 0: error_msg += f" (and {more} more)" error_msg += f". Valid types: {', '.join(sorted(VALID_ENERGY_TYPES))}" raise ValueError(error_msg) return energy_cards def validate_card_id_format(cards: dict[str, int], field_name: str) -> dict[str, int]: """Validate card IDs match expected format. Args: cards: Mapping of card IDs to quantities. field_name: Name of the field for error messages. Returns: The validated card dictionary. Raises: ValueError: If any card ID has invalid format. """ for card_id in cards: if not CARD_ID_PATTERN.match(card_id): raise ValueError( f"{field_name}: invalid card ID format '{card_id}'. " f"Expected format: set-number-name (e.g., a1-001-bulbasaur)" ) return cards class DeckCreateRequest(BaseModel): """Request model for creating a new deck. Attributes: name: Display name for the deck. cards: Mapping of card IDs to quantities (40 cards total by default). energy_cards: Mapping of energy types to quantities (20 total by default). deck_config: Deck rules from the frontend (defaults to standard rules). validate_ownership: If True, validates user owns all cards (campaign mode). description: Optional deck description. """ name: str = Field(..., min_length=1, max_length=100, description="Deck name") cards: dict[str, int] = Field(..., description="Card ID to quantity mapping") energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping") deck_config: DeckConfig = Field( default_factory=DeckConfig, description="Deck validation rules from frontend" ) validate_ownership: bool = Field( default=True, description="Validate card ownership (False for freeplay)" ) description: str | None = Field( default=None, max_length=500, description="Optional deck description" ) @field_validator("cards") @classmethod def validate_cards(cls, v: dict[str, int]) -> dict[str, int]: """Validate card IDs and quantities.""" validate_card_id_format(v, "cards") validate_card_quantities(v, "cards") return v @field_validator("energy_cards") @classmethod def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]: """Validate energy card types and quantities.""" validate_energy_types(v, "energy_cards") validate_card_quantities(v, "energy_cards") return v class DeckUpdateRequest(BaseModel): """Request model for updating a deck. All fields are optional - only provided fields are updated. If cards or energy_cards change, the deck is re-validated with deck_config. Attributes: name: New display name for the deck. cards: New card composition. energy_cards: New energy composition. deck_config: Deck rules from the frontend (defaults to standard rules). validate_ownership: If True, validates user owns all cards (campaign mode). description: New deck description. """ name: str | None = Field( default=None, min_length=1, max_length=100, description="New deck name" ) cards: dict[str, int] | None = Field(default=None, description="New card composition") energy_cards: dict[str, int] | None = Field(default=None, description="New energy composition") deck_config: DeckConfig = Field( default_factory=DeckConfig, description="Deck validation rules from frontend" ) validate_ownership: bool = Field( default=True, description="Validate card ownership (False for freeplay)" ) description: str | None = Field(default=None, max_length=500, description="New description") @field_validator("cards") @classmethod def validate_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None: """Validate card IDs and quantities if provided.""" if v is not None: validate_card_id_format(v, "cards") validate_card_quantities(v, "cards") return v @field_validator("energy_cards") @classmethod def validate_energy_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None: """Validate energy card types and quantities if provided.""" if v is not None: validate_energy_types(v, "energy_cards") validate_card_quantities(v, "energy_cards") return v class DeckResponse(BaseModel): """Response model for a deck. Includes the full deck composition and validation state. Attributes: id: Unique deck identifier. name: Display name of the deck. description: Optional deck description or notes. cards: Mapping of card IDs to quantities. energy_cards: Mapping of energy types to quantities. is_valid: Whether deck passes all validation rules. validation_errors: List of validation error messages (if any). is_starter: Whether this is a starter deck. starter_type: Type of starter deck (grass, fire, etc.) if applicable. created_at: When the deck was created. updated_at: When the deck was last modified. """ id: UUID = Field(..., description="Deck ID") name: str = Field(..., description="Deck name") description: str | None = Field(default=None, description="Deck description") cards: dict[str, int] = Field(..., description="Card ID to quantity mapping") energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping") is_valid: bool = Field(..., description="Whether deck is valid") validation_errors: list[str] | None = Field( default=None, description="Validation error messages" ) is_starter: bool = Field(default=False, description="Is starter deck") starter_type: str | None = Field(default=None, description="Starter deck type") created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") model_config = {"from_attributes": True} class DeckListResponse(BaseModel): """Response model for listing user's decks. Attributes: decks: List of user's decks. deck_count: Number of decks the user has. deck_limit: Maximum decks allowed (None for unlimited/premium). """ decks: list[DeckResponse] = Field(default_factory=list, description="User's decks") deck_count: int = Field(..., ge=0, description="Number of decks") deck_limit: int | None = Field(default=None, description="Max decks (None = unlimited)") class DeckValidateRequest(BaseModel): """Request model for validating a deck without saving. Used to check if a deck composition is valid before creating it. Attributes: cards: Card ID to quantity mapping to validate. energy_cards: Energy type to quantity mapping to validate. deck_config: Deck rules from the frontend (defaults to standard rules). validate_ownership: If True, validates user owns all cards (campaign mode). """ cards: dict[str, int] = Field(..., description="Card ID to quantity mapping") energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping") deck_config: DeckConfig = Field( default_factory=DeckConfig, description="Deck validation rules from frontend" ) validate_ownership: bool = Field( default=True, description="Validate card ownership (False for freeplay)" ) @field_validator("cards") @classmethod def validate_cards(cls, v: dict[str, int]) -> dict[str, int]: """Validate card IDs and quantities.""" validate_card_id_format(v, "cards") validate_card_quantities(v, "cards") return v @field_validator("energy_cards") @classmethod def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]: """Validate energy card types and quantities.""" validate_energy_types(v, "energy_cards") validate_card_quantities(v, "energy_cards") return v class DeckValidationResponse(BaseModel): """Response model for deck validation results. Attributes: is_valid: Whether the deck passes all validation rules. errors: List of validation error messages. """ is_valid: bool = Field(..., description="Whether deck is valid") errors: list[str] = Field(default_factory=list, description="Validation errors") class StarterDeckSelectRequest(BaseModel): """Request model for selecting a starter deck. Attributes: starter_type: Type of starter deck to select. deck_config: Deck rules from the frontend (defaults to standard rules). """ starter_type: str = Field( ..., description="Starter deck type (grass, fire, water, psychic, lightning)", ) deck_config: DeckConfig = Field( default_factory=DeckConfig, description="Deck validation rules from frontend" ) class StarterStatusResponse(BaseModel): """Response model for starter deck status. Attributes: has_starter: Whether user has selected a starter deck. starter_type: Type of starter deck selected (if any). """ has_starter: bool = Field(..., description="Has starter been selected") starter_type: str | None = Field(default=None, description="Selected starter type")