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>
328 lines
12 KiB
Python
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")
|