mantimon-tcg/backend/app/services/deck_validator.py
Cal Corum 58349c126a Phase 3: Collections + Decks - Services and DI architecture
Implemented with Repository Protocol pattern for offline fork support:
- CollectionService with PostgresCollectionRepository
- DeckService with PostgresDeckRepository
- DeckValidator with DeckConfig + CardService injection
- Starter deck definitions (5 types: grass, fire, water, psychic, lightning)
- Pydantic schemas for collection and deck APIs
- Unit tests for DeckValidator (32 tests passing)

Architecture follows pure dependency injection - no service locator patterns.
Added CLAUDE.md documenting DI requirements and patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:27:14 -06:00

230 lines
8.0 KiB
Python

"""Deck validation service for Mantimon TCG.
This module provides standalone deck validation logic that can be used
without database dependencies. It validates deck compositions against
the game rules defined in DeckConfig.
The validator is separate from DeckService to allow:
- Unit testing without database
- Validation before saving (API /validate endpoint)
- Reuse across different contexts (import/export, AI deck building)
Usage:
from app.core.config import DeckConfig
from app.services.card_service import CardService
from app.services.deck_validator import DeckValidator, DeckValidationResult
card_service = CardService()
card_service.load_all()
validator = DeckValidator(DeckConfig(), card_service)
# Validate without ownership check (freeplay mode)
result = validator.validate_deck(cards, energy_cards)
# Validate with ownership check (campaign mode)
result = validator.validate_deck(cards, energy_cards, owned_cards=user_collection)
"""
from dataclasses import dataclass, field
from app.core.config import DeckConfig
from app.services.card_service import CardService
@dataclass
class DeckValidationResult:
"""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.
Attributes:
is_valid: Whether the deck passes all validation rules.
errors: List of human-readable error messages.
"""
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.
Args:
error: Human-readable error message.
"""
self.is_valid = False
self.errors.append(error)
class DeckValidator:
"""Validates deck compositions against game rules.
This validator checks:
1. Total card count (40 cards in main deck)
2. Total energy count (20 energy cards)
3. Maximum copies per card (4)
4. Minimum Basic Pokemon requirement (1)
5. Card ID validity (card must exist)
6. Card ownership (optional, for campaign mode)
The validator uses DeckConfig for rule values, allowing different
game modes to have different rules if needed.
Attributes:
_config: The deck configuration with validation rules.
_card_service: The card service for card lookups.
"""
def __init__(self, config: DeckConfig, card_service: CardService) -> None:
"""Initialize the validator with dependencies.
Args:
config: Deck configuration with validation rules.
card_service: Card service for looking up card definitions.
"""
self._config = config
self._card_service = card_service
@property
def config(self) -> DeckConfig:
"""Get the deck configuration."""
return self._config
def validate_deck(
self,
cards: dict[str, int],
energy_cards: dict[str, int],
owned_cards: dict[str, int] | None = None,
) -> DeckValidationResult:
"""Validate a deck composition.
Checks all validation rules and returns all errors found (not just
the first one). This helps users fix all issues at once.
Args:
cards: Mapping of card IDs to quantities for the main deck.
energy_cards: Mapping of energy type names to quantities.
owned_cards: If provided, validates that the user owns enough
copies of each card. Pass None to skip ownership validation
(for freeplay mode).
Returns:
DeckValidationResult with is_valid status and list of errors.
Example:
result = validator.validate_deck(
cards={"a1-001-bulbasaur": 4, "a1-002-ivysaur": 4, ...},
energy_cards={"grass": 14, "colorless": 6},
owned_cards={"a1-001-bulbasaur": 10, ...}
)
if not result.is_valid:
for error in result.errors:
print(error)
"""
result = DeckValidationResult()
# 1. Validate total card count
total_cards = sum(cards.values())
if total_cards != self._config.min_size:
result.add_error(
f"Main deck must have exactly {self._config.min_size} cards, " f"got {total_cards}"
)
# 2. Validate total energy count
total_energy = sum(energy_cards.values())
if total_energy != self._config.energy_deck_size:
result.add_error(
f"Energy deck must have exactly {self._config.energy_deck_size} cards, "
f"got {total_energy}"
)
# 3. Validate max copies per card
for card_id, quantity in cards.items():
if quantity > self._config.max_copies_per_card:
result.add_error(
f"Card '{card_id}' has {quantity} copies, "
f"max allowed is {self._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 = self._card_service.get_card(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:
# Limit displayed invalid IDs to avoid huge error messages
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 < self._config.min_basic_pokemon:
result.add_error(
f"Deck must have at least {self._config.min_basic_pokemon} Basic Pokemon, "
f"got {basic_pokemon_count}"
)
# 6. Validate ownership if owned_cards provided (campaign mode)
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:
# Limit displayed 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(self, card_ids: list[str]) -> list[str]:
"""Check which card IDs are invalid.
Utility method to check card ID validity without full deck validation.
Args:
card_ids: List of card IDs to check.
Returns:
List of invalid card IDs (empty if all valid).
"""
invalid = []
for card_id in card_ids:
if self._card_service.get_card(card_id) is None:
invalid.append(card_id)
return invalid
def count_basic_pokemon(self, cards: dict[str, int]) -> int:
"""Count Basic Pokemon in a deck.
Utility method to count Basic Pokemon without full validation.
Args:
cards: Mapping of card IDs to quantities.
Returns:
Total number of Basic Pokemon cards in the deck.
"""
count = 0
for card_id, quantity in cards.items():
card_def = self._card_service.get_card(card_id)
if card_def and card_def.is_basic_pokemon():
count += quantity
return count