"""Deck validation functions for Mantimon TCG. This module provides pure validation functions that validate deck compositions against rules provided by the caller. The backend is stateless - rules come from the request via DeckConfig. Usage: from app.core.config import DeckConfig from app.services.deck_validator import validate_deck, ValidationResult result = validate_deck( cards={"a1-001-bulbasaur": 4, ...}, energy_cards={"grass": 14, "colorless": 6}, deck_config=DeckConfig(), # Or custom rules from request card_lookup=card_service.get_card, owned_cards=user_collection, # None to skip ownership check ) if not result.is_valid: for error in result.errors: print(error) """ from collections.abc import Callable from dataclasses import dataclass, field from app.core.config import DeckConfig from app.core.enums import EnergyType from app.core.models.card import CardDefinition # Set of valid energy type names (lowercase values from EnergyType enum) VALID_ENERGY_TYPES: frozenset[str] = frozenset(e.value for e in EnergyType) @dataclass class ValidationResult: """Result of deck validation. Contains validation status and all errors found. Multiple errors can be returned to help the user fix all issues at once. """ is_valid: bool = True errors: list[str] = field(default_factory=list) def add_error(self, error: str) -> None: """Add an error and mark as invalid.""" self.is_valid = False self.errors.append(error) def validate_deck( cards: dict[str, int], energy_cards: dict[str, int], deck_config: DeckConfig, card_lookup: Callable[[str], CardDefinition | None], owned_cards: dict[str, int] | None = None, ) -> ValidationResult: """Validate a deck composition against provided rules. This is a pure function - all inputs are provided by the caller, including the rules to validate against via DeckConfig. Args: cards: Mapping of card IDs to quantities for the main deck. energy_cards: Mapping of energy type names to quantities. deck_config: Deck rules from the caller (DeckConfig from app.core.config). card_lookup: Function to look up card definitions by ID. owned_cards: If provided, validates that the user owns enough copies of each card. Pass None to skip ownership validation. Returns: ValidationResult with is_valid status and list of errors. Example: result = validate_deck( cards={"a1-001-bulbasaur": 4}, energy_cards={"grass": 20}, deck_config=DeckConfig(min_size=40, energy_deck_size=20), card_lookup=card_service.get_card, ) """ result = ValidationResult() # 1. Validate total card count total_cards = sum(cards.values()) if total_cards != deck_config.min_size: result.add_error( f"Main deck must have exactly {deck_config.min_size} cards, got {total_cards}" ) # 2. Validate total energy count total_energy = sum(energy_cards.values()) if total_energy != deck_config.energy_deck_size: result.add_error( f"Energy deck must have exactly {deck_config.energy_deck_size} cards, " f"got {total_energy}" ) # 2b. Validate energy types are valid invalid_energy_types = [et for et in energy_cards if et not in VALID_ENERGY_TYPES] if invalid_energy_types: display_types = invalid_energy_types[:5] more = len(invalid_energy_types) - 5 error_msg = f"Invalid energy types: {', '.join(display_types)}" if more > 0: error_msg += f" (and {more} more)" result.add_error(error_msg) # 3. Validate max copies per card for card_id, quantity in cards.items(): if quantity > deck_config.max_copies_per_card: result.add_error( f"Card '{card_id}' has {quantity} copies, " f"max allowed is {deck_config.max_copies_per_card}" ) # 4 & 5. Validate card IDs exist and count Basic Pokemon basic_pokemon_count = 0 invalid_card_ids: list[str] = [] for card_id in cards: card_def = card_lookup(card_id) if card_def is None: invalid_card_ids.append(card_id) elif card_def.is_basic_pokemon(): basic_pokemon_count += cards[card_id] if invalid_card_ids: display_ids = invalid_card_ids[:5] more = len(invalid_card_ids) - 5 error_msg = f"Invalid card IDs: {', '.join(display_ids)}" if more > 0: error_msg += f" (and {more} more)" result.add_error(error_msg) # Check minimum Basic Pokemon requirement if basic_pokemon_count < deck_config.min_basic_pokemon: result.add_error( f"Deck must have at least {deck_config.min_basic_pokemon} Basic Pokemon, " f"got {basic_pokemon_count}" ) # 6. Validate ownership if owned_cards provided if owned_cards is not None: insufficient_cards: list[tuple[str, int, int]] = [] for card_id, required_qty in cards.items(): owned_qty = owned_cards.get(card_id, 0) if owned_qty < required_qty: insufficient_cards.append((card_id, required_qty, owned_qty)) if insufficient_cards: display_cards = insufficient_cards[:5] more = len(insufficient_cards) - 5 error_parts = [f"'{c[0]}' (need {c[1]}, own {c[2]})" for c in display_cards] error_msg = f"Insufficient cards: {', '.join(error_parts)}" if more > 0: error_msg += f" (and {more} more)" result.add_error(error_msg) return result def validate_cards_exist( card_ids: list[str], card_lookup: Callable[[str], CardDefinition | None], ) -> list[str]: """Check which card IDs are invalid. Args: card_ids: List of card IDs to check. card_lookup: Function to look up card definitions. Returns: List of invalid card IDs (empty if all valid). """ return [card_id for card_id in card_ids if card_lookup(card_id) is None] def count_basic_pokemon( cards: dict[str, int], card_lookup: Callable[[str], CardDefinition | None], ) -> int: """Count Basic Pokemon in a deck. Args: cards: Mapping of card IDs to quantities. card_lookup: Function to look up card definitions. Returns: Total number of Basic Pokemon cards in the deck. """ count = 0 for card_id, quantity in cards.items(): card_def = card_lookup(card_id) if card_def and card_def.is_basic_pokemon(): count += quantity return count def validate_energy_types(energy_types: list[str]) -> list[str]: """Check which energy type names are invalid. Args: energy_types: List of energy type names to check. Returns: List of invalid energy type names (empty if all valid). Example: invalid = validate_energy_types(["grass", "fire", "invalid"]) # Returns: ["invalid"] """ return [et for et in energy_types if et not in VALID_ENERGY_TYPES]