mantimon-tcg/backend/app/services/deck_validator.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

215 lines
7.1 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.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]