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>
230 lines
8.0 KiB
Python
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
|