mantimon-tcg/backend/app/schemas/deck.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

328 lines
12 KiB
Python

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