Critical fixes: - Add admin API key authentication for admin endpoints - Add race condition protection via unique partial index for starter decks - Make starter deck selection atomic with combined method Moderate fixes: - Fix DI pattern violation in validate_deck_endpoint - Add card ID format validation (regex pattern) - Add card quantity validation (1-99 range) - Fix exception chaining with from None (B904) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
185 lines
6.0 KiB
Python
185 lines
6.0 KiB
Python
"""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.models.card import CardDefinition
|
|
|
|
|
|
@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}"
|
|
)
|
|
|
|
# 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
|