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