- 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.
636 lines
24 KiB
Python
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()
|