mantimon-tcg/backend/app/core/models/card.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

636 lines
24 KiB
Python

"""Card models for the Mantimon TCG game engine.
This module defines two key model types:
- CardDefinition: Immutable template for a card (the "blueprint")
- CardInstance: Mutable in-game state for a card in play
The separation allows efficient memory usage (definitions are shared) while
maintaining per-card state during gameplay (damage, attached energy, etc.).
Usage:
# Define a card template
pikachu = CardDefinition(
id="pikachu_base_001",
name="Pikachu",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
attacks=[Attack(name="Thunder Shock", cost=[EnergyType.LIGHTNING], damage=20)],
)
# Create an instance for gameplay
instance = CardInstance(
instance_id="uuid-here",
definition_id="pikachu_base_001",
)
# Track damage during battle
instance.damage = 30
"""
from typing import Any
from pydantic import BaseModel, Field, model_validator
from app.core.enums import (
CardType,
EnergyType,
ModifierMode,
PokemonStage,
PokemonVariant,
StatusCondition,
TrainerType,
)
class Attack(BaseModel):
"""An attack that a Pokemon can use.
Attacks have an energy cost, base damage, and optional effect.
Effects are referenced by ID and resolved by the effect handler system.
Attributes:
name: Display name of the attack.
cost: List of energy types required to use this attack.
Colorless can be satisfied by any energy type.
damage: Base damage dealt by this attack. May be modified by effects.
damage_display: UI display string for variable damage (e.g., "30+", "50x").
Used when damage can vary based on conditions. If None, just show damage value.
effect_id: Optional reference to an effect handler for special effects.
effect_params: Parameters passed to the effect handler.
effect_description: Human-readable description of the effect.
"""
name: str
cost: list[EnergyType] = Field(default_factory=list)
damage: int = 0
damage_display: str | None = None
effect_id: str | None = None
effect_params: dict[str, Any] = Field(default_factory=dict)
effect_description: str | None = None
class Ability(BaseModel):
"""A special ability that a Pokemon has.
Abilities can be activated during the main phase (unless specified otherwise).
Like attacks, abilities reference effect handlers by ID.
Attributes:
name: Display name of the ability.
effect_id: Reference to an effect handler.
effect_params: Parameters passed to the effect handler.
effect_description: Human-readable description of the ability.
uses_per_turn: Maximum uses per turn. None means unlimited uses.
Default is 1 (once per turn), which matches standard Pokemon TCG rules.
"""
name: str
effect_id: str
effect_params: dict[str, Any] = Field(default_factory=dict)
effect_description: str | None = None
uses_per_turn: int | None = 1 # None = unlimited, 1 = once per turn (default)
class WeaknessResistance(BaseModel):
"""Weakness or resistance to a specific energy type.
This model supports both the global defaults from RulesConfig.combat and
per-card overrides. If mode/value are not specified, the handler will
use the game's CombatConfig defaults.
Attributes:
energy_type: The energy type this applies to.
mode: How to apply the modifier (multiplicative or additive).
If None, uses the game's CombatConfig default for this modifier type.
value: The modifier value to apply.
- For multiplicative: damage * value (e.g., 2 for x2)
- For additive: damage + value (e.g., +20 or -30)
If None, uses the game's CombatConfig default.
Examples:
# Use game defaults (mode and value from CombatConfig)
WeaknessResistance(energy_type=EnergyType.FIRE)
# Override just the value (still uses default mode)
WeaknessResistance(energy_type=EnergyType.FIRE, value=3) # x3 if default is multiplicative
# Full override (card-specific behavior)
WeaknessResistance(
energy_type=EnergyType.FIRE,
mode=ModifierMode.ADDITIVE,
value=40, # +40 damage instead of x2
)
Legacy Support:
The old 'modifier' field is deprecated but still works for backwards
compatibility. If 'modifier' is provided without 'value', it will be
used as the value. The mode will still default to CombatConfig.
"""
energy_type: EnergyType
mode: ModifierMode | None = None
value: int | None = None
# Legacy field for backwards compatibility
modifier: int | None = None
def get_value(self, default: int) -> int:
"""Get the modifier value, falling back to default if not specified.
Args:
default: The default value from CombatConfig.
Returns:
The value to use for this modifier.
"""
if self.value is not None:
return self.value
if self.modifier is not None:
return self.modifier
return default
def get_mode(self, default: ModifierMode) -> ModifierMode:
"""Get the modifier mode, falling back to default if not specified.
Args:
default: The default mode from CombatConfig.
Returns:
The mode to use for this modifier.
"""
return self.mode if self.mode is not None else default
class CardDefinition(BaseModel):
"""Immutable template for a card.
This represents the "blueprint" of a card - all the static information
that doesn't change during gameplay. Multiple CardInstances can reference
the same CardDefinition.
The card_type field determines which other fields are relevant:
- POKEMON: stage, variant, hp, pokemon_type, attacks, abilities, weakness, resistance, retreat_cost
- TRAINER: trainer_type, effect_id, effect_params
- ENERGY: energy_type, energy_provides
Attributes:
id: Unique identifier for this card definition (e.g., "pikachu_base_001").
name: Display name of the card.
card_type: Whether this is a Pokemon, Trainer, or Energy card.
stage: Evolution stage - BASIC, STAGE_1, or STAGE_2 (Pokemon only).
variant: Special variant - NORMAL, EX, GX, V, VMAX, VSTAR (Pokemon only).
Affects knockout points but not evolution mechanics.
evolves_from: Name of the Pokemon this evolves from (Stage 1/2, VMAX, VSTAR only).
hp: Hit points (Pokemon only).
pokemon_type: Energy type of this Pokemon (for weakness/resistance).
attacks: List of attacks this Pokemon can use.
abilities: List of abilities this Pokemon has.
weakness: Weakness to an energy type (Pokemon only).
resistance: Resistance to an energy type (Pokemon only).
retreat_cost: Number of energy to discard to retreat (Pokemon only).
trainer_type: Subtype of trainer card (Trainer only).
effect_id: Effect handler for this card (Trainer/special Energy).
effect_params: Parameters for the effect handler.
effect_description: Human-readable description of the card's effect.
energy_type: Type of energy this card provides (Energy only).
energy_provides: List of energy types this card provides.
Basic energy provides one of its type. Special energy may provide multiple.
rarity: Card rarity (common, uncommon, rare, etc.).
set_id: Which card set this belongs to.
image_url: URL to the card image (CDN).
image_path: Local path to the card image (e.g., "pokemon/a1/001-bulbasaur.webp").
illustrator: Artist who illustrated this card.
flavor_text: Flavor text on the card (Pokemon only, typically).
"""
id: str
name: str
card_type: CardType
# Pokemon-specific fields
stage: PokemonStage | None = None
variant: PokemonVariant = PokemonVariant.NORMAL
evolves_from: str | None = None
hp: int | None = None
pokemon_type: EnergyType | None = None
attacks: list[Attack] = Field(default_factory=list)
abilities: list[Ability] = Field(default_factory=list)
weakness: WeaknessResistance | None = None
resistance: WeaknessResistance | None = None
retreat_cost: int = 0
# Trainer-specific fields
trainer_type: TrainerType | None = None
effect_id: str | None = None
effect_params: dict[str, Any] = Field(default_factory=dict)
effect_description: str | None = None
# Energy-specific fields
energy_type: EnergyType | None = None
energy_provides: list[EnergyType] = Field(default_factory=list)
# Metadata
rarity: str = "common"
set_id: str = ""
image_url: str | None = None
image_path: str | None = None # Local path: "pokemon/a1/001-bulbasaur.webp"
illustrator: str | None = None
flavor_text: str | None = None
@model_validator(mode="after")
def validate_card_type_fields(self) -> "CardDefinition":
"""Validate that required fields are set based on card_type.
Ensures that:
- Pokemon cards have hp, stage, and pokemon_type
- Stage 1/2 and VMAX/VSTAR Pokemon have evolves_from
- Trainer cards have trainer_type
- Energy cards have energy_type (auto-fills energy_provides if empty)
Raises:
ValueError: If required fields are missing for the card type.
"""
if self.card_type == CardType.POKEMON:
if self.hp is None:
raise ValueError("Pokemon cards must have 'hp' set")
if self.hp <= 0:
raise ValueError("Pokemon 'hp' must be positive")
if self.stage is None:
raise ValueError("Pokemon cards must have 'stage' set")
if self.pokemon_type is None:
raise ValueError("Pokemon cards must have 'pokemon_type' set")
# Evolution chain validation
if (
self.stage in (PokemonStage.STAGE_1, PokemonStage.STAGE_2)
and self.evolves_from is None
):
raise ValueError(f"{self.stage.value} Pokemon must have 'evolves_from' set")
if (
self.variant in (PokemonVariant.VMAX, PokemonVariant.VSTAR)
and self.evolves_from is None
):
raise ValueError(f"{self.variant.value} Pokemon must have 'evolves_from' set")
elif self.card_type == CardType.TRAINER:
if self.trainer_type is None:
raise ValueError("Trainer cards must have 'trainer_type' set")
elif self.card_type == CardType.ENERGY:
if self.energy_type is None:
raise ValueError("Energy cards must have 'energy_type' set")
# Auto-fill energy_provides if empty
if not self.energy_provides:
self.energy_provides = [self.energy_type]
return self
def is_pokemon(self) -> bool:
"""Check if this is a Pokemon card."""
return self.card_type == CardType.POKEMON
def is_trainer(self) -> bool:
"""Check if this is a Trainer card."""
return self.card_type == CardType.TRAINER
def is_energy(self) -> bool:
"""Check if this is an Energy card."""
return self.card_type == CardType.ENERGY
def is_basic_pokemon(self) -> bool:
"""Check if this is a Basic Pokemon (can be played directly to bench)."""
if not self.is_pokemon():
return False
return self.stage == PokemonStage.BASIC
def is_evolution(self) -> bool:
"""Check if this is an evolution Pokemon (Stage 1 or Stage 2)."""
if not self.is_pokemon():
return False
return self.stage in (PokemonStage.STAGE_1, PokemonStage.STAGE_2)
def requires_evolution_from_variant(self) -> bool:
"""Check if this variant requires evolving from another variant.
VMAX and VSTAR must evolve from a V Pokemon.
"""
return self.variant in (PokemonVariant.VMAX, PokemonVariant.VSTAR)
def knockout_points(self) -> int:
"""Get the default knockout points for this Pokemon based on variant.
Note: Actual points should come from RulesConfig.prizes.points_for_knockout()
for configurability. This is a convenience method for the common case.
"""
if not self.is_pokemon():
return 0
points_map = {
PokemonVariant.NORMAL: 1,
PokemonVariant.EX: 2,
PokemonVariant.GX: 2,
PokemonVariant.V: 2,
PokemonVariant.VMAX: 3,
PokemonVariant.VSTAR: 3,
}
return points_map.get(self.variant, 1)
class CardInstance(BaseModel):
"""A card instance in play with mutable state.
While CardDefinition is the template, CardInstance represents a specific
card during gameplay. It tracks damage, attached energy, status conditions,
and other per-card state.
The definition_id links back to the CardDefinition in the game's card registry.
Stat Modifiers:
Card effects can modify base stats using the modifier fields. These are
additive: effective_stat = base_stat + modifier. For example, a Tool card
that grants +20 HP would set hp_modifier=20.
Attributes:
instance_id: Unique identifier for this specific instance (UUID).
definition_id: Reference to the CardDefinition.id.
damage: Current damage on this card (Pokemon only).
attached_energy: Energy cards attached to this Pokemon. These CardInstance
objects are stored directly on the Pokemon, not in any zone.
attached_tools: Tool cards attached to this Pokemon. These CardInstance
objects are stored directly on the Pokemon, not in any zone.
cards_underneath: Evolution stack - previous evolution stages are stored
here when a Pokemon evolves. Index 0 is the oldest (Basic), and
later indices are more recent evolutions. When the Pokemon is knocked
out, all cards in this stack go to the discard pile.
status_conditions: Active status conditions on this Pokemon.
ability_uses_this_turn: Number of times each ability has been used this turn.
Maps ability index to use count. Compared against Ability.uses_per_turn
to determine if more uses of that specific ability are allowed.
hp_modifier: Additive modifier to the Pokemon's base HP. Can be positive
(e.g., from Tool cards) or negative (e.g., from effects).
retreat_cost_modifier: Additive modifier to retreat cost. Negative values
reduce the cost (e.g., Float Stone sets this to -99 to effectively
make retreat free). Effective retreat cost is clamped to minimum 0.
damage_modifier: Additive modifier to damage dealt by this Pokemon's attacks.
Positive values increase damage, negative reduce it.
attack_cost_overrides: Override the energy cost for specific attacks. Maps
attack index (0-based) to a new list of EnergyType requirements. If an
attack index is not in this dict, the base cost from CardDefinition is used.
Example: {0: [EnergyType.COLORLESS, EnergyType.COLORLESS]} makes the first
attack cost 2 colorless instead of its normal cost.
evolved_from_instance_id: The CardInstance this evolved from (deprecated,
use cards_underneath instead for the full evolution stack).
turn_played: The turn number when this card was played/evolved.
Used for evolution timing rules.
turn_evolved: The turn number when this card last evolved.
Used to prevent same-turn evolution chains.
"""
instance_id: str
definition_id: str
# Battle state (Pokemon only)
damage: int = 0
attached_energy: list["CardInstance"] = Field(default_factory=list)
attached_tools: list["CardInstance"] = Field(default_factory=list)
cards_underneath: list["CardInstance"] = Field(default_factory=list)
status_conditions: list[StatusCondition] = Field(default_factory=list)
ability_uses_this_turn: dict[int, int] = Field(default_factory=dict)
# Stat modifiers (applied by card effects, tools, abilities, etc.)
hp_modifier: int = 0
retreat_cost_modifier: int = 0
damage_modifier: int = 0
attack_cost_overrides: dict[int, list[EnergyType]] = Field(default_factory=dict)
# Evolution tracking
evolved_from_instance_id: str | None = None
turn_played: int | None = None
turn_evolved: int | None = None
def effective_hp(self, base_hp: int) -> int:
"""Calculate effective max HP including modifiers.
Args:
base_hp: The Pokemon's base HP (from CardDefinition.hp).
Returns:
Effective max HP (minimum 1 to prevent instant KO from negative modifiers).
"""
return max(1, base_hp + self.hp_modifier)
def effective_retreat_cost(self, base_cost: int) -> int:
"""Calculate effective retreat cost including modifiers.
Args:
base_cost: The Pokemon's base retreat cost (from CardDefinition.retreat_cost).
Returns:
Effective retreat cost (minimum 0, can't be negative).
"""
return max(0, base_cost + self.retreat_cost_modifier)
def effective_attack_cost(
self, attack_index: int, base_cost: list[EnergyType]
) -> list[EnergyType]:
"""Get the effective energy cost for an attack, including any overrides.
Args:
attack_index: The index of the attack in CardDefinition.attacks (0-based).
base_cost: The attack's base energy cost (from Attack.cost).
Returns:
The effective energy cost - either the override if set, or the base cost.
Example:
# An opponent's effect makes your first attack cost 2 more colorless
instance.attack_cost_overrides[0] = base_cost + [EnergyType.COLORLESS] * 2
# A control effect completely changes the cost
instance.attack_cost_overrides[1] = [EnergyType.PSYCHIC, EnergyType.PSYCHIC]
"""
if attack_index in self.attack_cost_overrides:
return self.attack_cost_overrides[attack_index]
return base_cost
def is_knocked_out(self, base_hp: int) -> bool:
"""Check if this Pokemon is knocked out.
Args:
base_hp: The Pokemon's base HP (from CardDefinition.hp).
Returns:
True if damage >= effective HP.
"""
return self.damage >= self.effective_hp(base_hp)
def remaining_hp(self, base_hp: int) -> int:
"""Calculate remaining HP.
Args:
base_hp: The Pokemon's base HP (from CardDefinition.hp).
Returns:
Remaining HP (minimum 0).
"""
return max(0, self.effective_hp(base_hp) - self.damage)
def has_status(self, status: StatusCondition) -> bool:
"""Check if this Pokemon has a specific status condition."""
return status in self.status_conditions
def add_status(self, status: StatusCondition) -> None:
"""Add a status condition, handling overrides.
ASLEEP, PARALYZED, and CONFUSED override each other.
POISONED and BURNED stack with everything.
"""
# These three override each other
overriding = {StatusCondition.ASLEEP, StatusCondition.PARALYZED, StatusCondition.CONFUSED}
if status in overriding:
# Remove any existing overriding conditions
self.status_conditions = [s for s in self.status_conditions if s not in overriding]
if status not in self.status_conditions:
self.status_conditions.append(status)
def remove_status(self, status: StatusCondition) -> None:
"""Remove a status condition."""
if status in self.status_conditions:
self.status_conditions.remove(status)
def clear_all_status(self) -> None:
"""Remove all status conditions (e.g., when Pokemon retreats or evolves)."""
self.status_conditions.clear()
def reset_turn_state(self) -> None:
"""Reset per-turn state counters. Called at the start of each turn."""
self.ability_uses_this_turn.clear()
def can_use_ability(self, ability: Ability, ability_index: int) -> bool:
"""Check if this Pokemon can use the given ability.
Compares the use count for this specific ability against its uses_per_turn limit.
If uses_per_turn is None, the ability has no usage limit.
Args:
ability: The Ability to check.
ability_index: The index of the ability in the Pokemon's ability list.
Returns:
True if the ability can be used (hasn't hit its per-turn limit).
"""
if ability.uses_per_turn is None:
return True # Unlimited uses
uses = self.ability_uses_this_turn.get(ability_index, 0)
return uses < ability.uses_per_turn
def get_ability_uses(self, ability_index: int) -> int:
"""Get the number of times a specific ability has been used this turn.
Args:
ability_index: The index of the ability.
Returns:
Number of times the ability has been used this turn.
"""
return self.ability_uses_this_turn.get(ability_index, 0)
def increment_ability_uses(self, ability_index: int) -> None:
"""Increment the usage counter for a specific ability.
Args:
ability_index: The index of the ability that was used.
"""
current = self.ability_uses_this_turn.get(ability_index, 0)
self.ability_uses_this_turn[ability_index] = current + 1
def can_evolve_this_turn(self, current_turn: int) -> bool:
"""Check if this Pokemon can evolve this turn.
Pokemon cannot evolve:
- The same turn they were played
- The same turn they already evolved
Args:
current_turn: The current turn number.
Returns:
True if this Pokemon can evolve.
"""
# Can't evolve same turn as played
if self.turn_played == current_turn:
return False
# Can't evolve same turn as previous evolution
return self.turn_evolved != current_turn
def energy_count(self) -> int:
"""Get the number of attached energy cards."""
return len(self.attached_energy)
def attach_energy(self, energy_card: "CardInstance") -> None:
"""Attach an energy card to this Pokemon.
The energy CardInstance is stored directly on this Pokemon, not in any zone.
When the Pokemon is knocked out, attached energy goes to the owner's discard.
Args:
energy_card: The CardInstance of the energy card to attach.
"""
self.attached_energy.append(energy_card)
def detach_energy(self, energy_instance_id: str) -> "CardInstance | None":
"""Detach an energy card from this Pokemon.
Args:
energy_instance_id: The instance_id of the energy card to detach.
Returns:
The detached CardInstance, or None if not found.
"""
for i, energy in enumerate(self.attached_energy):
if energy.instance_id == energy_instance_id:
return self.attached_energy.pop(i)
return None
def attach_tool(self, tool_card: "CardInstance") -> None:
"""Attach a tool card to this Pokemon.
The tool CardInstance is stored directly on this Pokemon, not in any zone.
When the Pokemon is knocked out, attached tools go to the owner's discard.
Args:
tool_card: The CardInstance of the tool card to attach.
"""
self.attached_tools.append(tool_card)
def detach_tool(self, tool_instance_id: str) -> "CardInstance | None":
"""Detach a tool card from this Pokemon.
Args:
tool_instance_id: The instance_id of the tool card to detach.
Returns:
The detached CardInstance, or None if not found.
"""
for i, tool in enumerate(self.attached_tools):
if tool.instance_id == tool_instance_id:
return self.attached_tools.pop(i)
return None
def get_all_attached_cards(self) -> list["CardInstance"]:
"""Get all cards attached to this Pokemon.
Returns:
List of all attached energy and tool CardInstances.
"""
return self.attached_energy + self.attached_tools
# Rebuild model to resolve forward references for self-referential types
# (CardInstance contains list[CardInstance] for attached_energy, attached_tools, cards_underneath)
CardInstance.model_rebuild()