mantimon-tcg/backend/app/services/card_service.py
Cal Corum 934aa4c443 Add CardService and card data conversion pipeline
- Rename data/cards/ to data/raw/ for scraped data
- Add data/definitions/ as authoritative card data source
- Add convert_cards.py script to transform raw -> definitions
- Generate 378 card definitions (344 Pokemon, 24 Trainers, 10 Energy)
- Add CardService for loading and querying card definitions
  - In-memory indexes for fast lookups by type, set, pokemon_type
  - search() with multiple filter criteria
  - get_all_cards() for GameEngine integration
- Add SetInfo model for set metadata
- Update Attack model with damage_display field for variable damage
- Update CardDefinition with image_path, illustrator, flavor_text
- Add 45 tests (21 converter + 24 CardService)
- Update scraper output path to data/raw/

Card data is JSON-authoritative (no database) to support offline fork goal.
2026-01-27 14:16:40 -06:00

380 lines
12 KiB
Python

"""Card service for loading and serving card definitions.
This service loads card definitions from JSON files in data/definitions/ and
serves them to the game engine and other parts of the application.
Cards are loaded into memory at startup for fast lookups. The service maintains
several indexes for efficient querying by type, set, and other criteria.
Usage:
card_service = CardService()
await card_service.load_all()
# Get a single card
pikachu = card_service.get_card("a1-025-pikachu")
# Get all cards for game engine
registry = card_service.get_all_cards()
# Search cards
grass_pokemon = card_service.search(
card_type=CardType.POKEMON,
pokemon_type=EnergyType.GRASS,
)
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from pydantic import BaseModel
from app.core.enums import CardType, EnergyType, PokemonStage, PokemonVariant
from app.core.models.card import CardDefinition
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
# TODO: Update CDN_BASE_URL when CDN is configured
CDN_BASE_URL = "https://cdn.mantimon.com/cards"
class SetInfo(BaseModel):
"""Metadata about a card set.
Attributes:
code: Unique identifier for the set (e.g., "a1").
name: Display name of the set (e.g., "Genetic Apex").
card_count: Total number of cards in this set.
"""
code: str
name: str
card_count: int
class CardService:
"""Load and serve card definitions from JSON files.
This service loads all card definitions into memory at startup for fast
lookups. Cards are immutable after loading - all lookups are in-memory.
The service maintains several indexes for efficient querying:
- By ID: O(1) lookup for any card
- By type: O(1) to get all Pokemon, Trainers, or Energy
- By set: O(1) to get all cards from a set
- By Pokemon type: O(1) to get all Pokemon of a specific type
Attributes:
definitions_dir: Path to the definitions directory.
"""
def __init__(self, definitions_dir: Path | None = None) -> None:
"""Initialize the card service.
Args:
definitions_dir: Path to the data/definitions directory.
If None, uses the default location relative to this file.
"""
if definitions_dir is None:
# Default: backend/data/definitions/
self._definitions_dir = Path(__file__).parent.parent.parent / "data" / "definitions"
else:
self._definitions_dir = definitions_dir
# Primary storage
self._cards: dict[str, CardDefinition] = {}
# Indexes for fast querying
self._by_type: dict[CardType, list[str]] = {t: [] for t in CardType}
self._by_set: dict[str, list[str]] = {}
self._by_pokemon_type: dict[EnergyType, list[str]] = {t: [] for t in EnergyType}
# Set metadata
self._sets: list[SetInfo] = []
# Load state
self._loaded: bool = False
async def load_all(self) -> None:
"""Load all card definitions into memory.
This should be called once at application startup. After loading,
all card lookups are synchronous in-memory operations.
Raises:
FileNotFoundError: If the definitions directory doesn't exist.
ValueError: If any card definition is invalid.
"""
if self._loaded:
logger.warning("CardService.load_all() called but cards already loaded")
return
if not self._definitions_dir.exists():
raise FileNotFoundError(f"Definitions directory not found: {self._definitions_dir}")
# Load index file for set metadata
index_path = self._definitions_dir / "_index.json"
if index_path.exists():
with open(index_path) as f:
index_data = json.load(f)
for set_code, set_info in index_data.get("sets", {}).items():
self._sets.append(
SetInfo(
code=set_code,
name=set_info.get("name", set_code),
card_count=set_info.get("card_count", 0),
)
)
# Load Pokemon cards
await self._load_card_type("pokemon", CardType.POKEMON)
# Load Trainer cards
await self._load_card_type("trainer", CardType.TRAINER)
# Load Energy cards
await self._load_energy_cards()
self._loaded = True
logger.info(
f"CardService loaded {len(self._cards)} cards "
f"({len(self._by_type[CardType.POKEMON])} Pokemon, "
f"{len(self._by_type[CardType.TRAINER])} Trainers, "
f"{len(self._by_type[CardType.ENERGY])} Energy)"
)
async def _load_card_type(self, subdir: str, card_type: CardType) -> None:
"""Load all cards of a specific type from a subdirectory.
Args:
subdir: Subdirectory name (e.g., "pokemon", "trainer").
card_type: The CardType these cards should be.
"""
type_dir = self._definitions_dir / subdir
if not type_dir.exists():
logger.debug(f"Directory {type_dir} does not exist, skipping")
return
for set_dir in type_dir.iterdir():
if not set_dir.is_dir():
continue
set_code = set_dir.name
if set_code not in self._by_set:
self._by_set[set_code] = []
for card_file in set_dir.glob("*.json"):
card = await self._load_card_file(card_file)
if card:
self._index_card(card)
async def _load_energy_cards(self) -> None:
"""Load energy cards from the energy subdirectory."""
energy_dir = self._definitions_dir / "energy"
if not energy_dir.exists():
logger.debug(f"Directory {energy_dir} does not exist, skipping")
return
# Energy can be in subdirectories (e.g., energy/basic/) or directly in energy/
for item in energy_dir.iterdir():
if item.is_file() and item.suffix == ".json":
card = await self._load_card_file(item)
if card:
self._index_card(card)
elif item.is_dir():
for card_file in item.glob("*.json"):
card = await self._load_card_file(card_file)
if card:
self._index_card(card)
async def _load_card_file(self, file_path: Path) -> CardDefinition | None:
"""Load and validate a single card definition file.
Args:
file_path: Path to the JSON file.
Returns:
CardDefinition if valid, None if loading failed.
"""
try:
with open(file_path) as f:
card_data = json.load(f)
return CardDefinition.model_validate(card_data)
except Exception as e:
logger.error(f"Failed to load card from {file_path}: {e}")
return None
def _index_card(self, card: CardDefinition) -> None:
"""Add a card to all indexes.
Args:
card: The CardDefinition to index.
"""
# Primary storage
self._cards[card.id] = card
# Type index
self._by_type[card.card_type].append(card.id)
# Set index
if card.set_id:
if card.set_id not in self._by_set:
self._by_set[card.set_id] = []
self._by_set[card.set_id].append(card.id)
# Pokemon type index (for Pokemon cards only)
if card.card_type == CardType.POKEMON and card.pokemon_type:
self._by_pokemon_type[card.pokemon_type].append(card.id)
def get_card(self, card_id: str) -> CardDefinition | None:
"""Get a card by its ID.
Args:
card_id: Unique card identifier (e.g., "a1-025-pikachu").
Returns:
CardDefinition if found, None otherwise.
"""
return self._cards.get(card_id)
def get_all_cards(self) -> dict[str, CardDefinition]:
"""Get all cards as a registry dict.
This is the format expected by GameEngine.create_game().
Returns:
Dictionary mapping card ID to CardDefinition.
"""
return self._cards.copy()
def get_cards_by_ids(self, card_ids: list[str]) -> list[CardDefinition]:
"""Get multiple cards by their IDs.
Args:
card_ids: List of card IDs to retrieve.
Returns:
List of CardDefinitions in the same order as input.
Raises:
KeyError: If any card ID is not found.
"""
result = []
for card_id in card_ids:
card = self._cards.get(card_id)
if card is None:
raise KeyError(f"Card not found: {card_id}")
result.append(card)
return result
def search(
self,
name: str | None = None,
card_type: CardType | None = None,
pokemon_type: EnergyType | None = None,
set_id: str | None = None,
stage: PokemonStage | None = None,
variant: PokemonVariant | None = None,
) -> list[CardDefinition]:
"""Search cards by various criteria.
All provided criteria are AND-ed together. Omitted criteria match all cards.
Args:
name: Partial name match (case-insensitive).
card_type: Filter by card type (Pokemon, Trainer, Energy).
pokemon_type: Filter by Pokemon energy type (e.g., GRASS, FIRE).
set_id: Filter by set code (e.g., "a1").
stage: Filter by Pokemon stage (BASIC, STAGE_1, STAGE_2).
variant: Filter by Pokemon variant (NORMAL, EX, etc.).
Returns:
List of matching CardDefinitions.
"""
# Start with the smallest candidate set based on indexed filters
if pokemon_type is not None:
candidate_ids = set(self._by_pokemon_type.get(pokemon_type, []))
elif card_type is not None:
candidate_ids = set(self._by_type.get(card_type, []))
elif set_id is not None:
candidate_ids = set(self._by_set.get(set_id, []))
else:
candidate_ids = set(self._cards.keys())
results = []
for card_id in candidate_ids:
card = self._cards[card_id]
# Apply all filters
if card_type is not None and card.card_type != card_type:
continue
if pokemon_type is not None and card.pokemon_type != pokemon_type:
continue
if set_id is not None and card.set_id != set_id:
continue
if stage is not None and card.stage != stage:
continue
if variant is not None and card.variant != variant:
continue
if name is not None and name.lower() not in card.name.lower():
continue
results.append(card)
return results
def get_set_cards(self, set_id: str) -> list[CardDefinition]:
"""Get all cards from a specific set.
Args:
set_id: Set code (e.g., "a1", "a1a").
Returns:
List of CardDefinitions from that set.
"""
card_ids = self._by_set.get(set_id, [])
return [self._cards[card_id] for card_id in card_ids]
def get_sets(self) -> list[SetInfo]:
"""Get list of available sets with metadata.
Returns:
List of SetInfo objects.
"""
return self._sets.copy()
@property
def card_count(self) -> int:
"""Total number of loaded cards."""
return len(self._cards)
@property
def is_loaded(self) -> bool:
"""Whether cards have been loaded."""
return self._loaded
# Singleton instance for application use
_card_service: CardService | None = None
def get_card_service() -> CardService:
"""Get the global CardService instance.
Creates the instance if it doesn't exist. Note that load_all() must still
be called before using the service.
Returns:
The global CardService instance.
"""
global _card_service
if _card_service is None:
_card_service = CardService()
return _card_service