diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 96031dc..534c6b8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,7 +24,7 @@ Usage: from pydantic import BaseModel, Field -from app.core.models.enums import EnergyType, PokemonStage +from app.core.models.enums import EnergyType, PokemonVariant class DeckConfig(BaseModel): @@ -84,50 +84,50 @@ class PrizeConfig(BaseModel): score points instead of taking prize cards. This simplifies the game while maintaining the knockout scoring mechanic. + Knockout points are determined by the Pokemon's variant (EX, V, GX, etc.), + not by its evolution stage. A Basic EX is worth the same as a Stage 2 EX. + Attributes: count: Number of points needed to win (or prize cards if using classic rules). - per_knockout_basic: Points scored for knocking out a basic Pokemon. - per_knockout_stage_1: Points scored for knocking out a Stage 1 Pokemon. - per_knockout_stage_2: Points scored for knocking out a Stage 2 Pokemon. + per_knockout_normal: Points for knocking out a normal (non-variant) Pokemon. per_knockout_ex: Points scored for knocking out an EX Pokemon. + per_knockout_gx: Points scored for knocking out a GX Pokemon. per_knockout_v: Points scored for knocking out a V Pokemon. per_knockout_vmax: Points scored for knocking out a VMAX Pokemon. - per_knockout_gx: Points scored for knocking out a GX Pokemon. + per_knockout_vstar: Points scored for knocking out a VSTAR Pokemon (3 points). use_prize_cards: If True, use classic prize card mechanic instead of points. prize_selection_random: If True, prize cards are taken randomly (classic). If False, player chooses which prize to take. """ count: int = 4 - per_knockout_basic: int = 1 - per_knockout_stage_1: int = 1 - per_knockout_stage_2: int = 1 + per_knockout_normal: int = 1 per_knockout_ex: int = 2 + per_knockout_gx: int = 2 per_knockout_v: int = 2 per_knockout_vmax: int = 3 - per_knockout_gx: int = 2 + per_knockout_vstar: int = 3 use_prize_cards: bool = False prize_selection_random: bool = True - def points_for_knockout(self, stage: PokemonStage) -> int: - """Get the number of points scored for knocking out a Pokemon of the given stage. + def points_for_knockout(self, variant: PokemonVariant) -> int: + """Get the number of points scored for knocking out a Pokemon of the given variant. Args: - stage: The PokemonStage of the knocked out Pokemon. + variant: The PokemonVariant of the knocked out Pokemon. Returns: Number of points to score. """ - stage_map = { - PokemonStage.BASIC: self.per_knockout_basic, - PokemonStage.STAGE_1: self.per_knockout_stage_1, - PokemonStage.STAGE_2: self.per_knockout_stage_2, - PokemonStage.EX: self.per_knockout_ex, - PokemonStage.V: self.per_knockout_v, - PokemonStage.VMAX: self.per_knockout_vmax, - PokemonStage.GX: self.per_knockout_gx, + variant_map = { + PokemonVariant.NORMAL: self.per_knockout_normal, + PokemonVariant.EX: self.per_knockout_ex, + PokemonVariant.GX: self.per_knockout_gx, + PokemonVariant.V: self.per_knockout_v, + PokemonVariant.VMAX: self.per_knockout_vmax, + PokemonVariant.VSTAR: self.per_knockout_vstar, } - return stage_map.get(stage, self.per_knockout_basic) + return variant_map.get(variant, self.per_knockout_normal) class FirstTurnConfig(BaseModel): diff --git a/backend/app/core/models/actions.py b/backend/app/core/models/actions.py new file mode 100644 index 0000000..9a4463a --- /dev/null +++ b/backend/app/core/models/actions.py @@ -0,0 +1,276 @@ +"""Player action models for the Mantimon TCG game engine. + +This module defines all actions a player can take during their turn. +Actions are modeled as a discriminated union using Pydantic's Literal types, +enabling type-safe action handling and automatic JSON serialization. + +The union type allows code like: + match action: + case PlayPokemonAction(): + handle_play_pokemon(action) + case AttackAction(): + handle_attack(action) + +Usage: + # Create an action + action = AttackAction(attack_index=0) + + # Serialize to JSON for WebSocket + json_str = action.model_dump_json() + + # Parse incoming action from client + data = {"type": "attack", "attack_index": 0} + action = parse_action(data) # Returns AttackAction +""" + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Field + + +class PlayPokemonAction(BaseModel): + """Play a Basic Pokemon from hand to the bench (or active during setup). + + This action places a Basic Pokemon card from the player's hand onto their + bench. During the setup phase, it can optionally be placed as the active + Pokemon. + + Attributes: + type: Discriminator field, always "play_pokemon". + card_instance_id: The CardInstance.instance_id of the Pokemon to play. + to_active: If True and during setup, place as active Pokemon. + Ignored during normal gameplay. + """ + + type: Literal["play_pokemon"] = "play_pokemon" + card_instance_id: str + to_active: bool = False + + +class EvolvePokemonAction(BaseModel): + """Evolve a Pokemon in play. + + Places an evolution card from hand onto a Pokemon in play, provided + evolution rules are satisfied (not same turn as played/evolved, etc.). + + Attributes: + type: Discriminator field, always "evolve". + evolution_card_id: The CardInstance.instance_id of the evolution card. + target_pokemon_id: The CardInstance.instance_id of the Pokemon to evolve. + """ + + type: Literal["evolve"] = "evolve" + evolution_card_id: str + target_pokemon_id: str + + +class AttachEnergyAction(BaseModel): + """Attach an energy card from hand (or energy deck) to a Pokemon. + + Energy can be attached to the active Pokemon or any benched Pokemon. + Limited to once per turn by default (configurable via RulesConfig). + + Attributes: + type: Discriminator field, always "attach_energy". + energy_card_id: The CardInstance.instance_id of the energy card. + target_pokemon_id: The CardInstance.instance_id of the Pokemon to attach to. + from_energy_deck: If True, the energy comes from the energy deck + (Pokemon Pocket style) rather than hand. + """ + + type: Literal["attach_energy"] = "attach_energy" + energy_card_id: str + target_pokemon_id: str + from_energy_deck: bool = False + + +class PlayTrainerAction(BaseModel): + """Play a Trainer card from hand. + + The effect of the trainer card is resolved by the effect handler system. + Some trainer cards require targets (specified in the targets list). + + Attributes: + type: Discriminator field, always "play_trainer". + card_instance_id: The CardInstance.instance_id of the trainer card. + targets: List of target CardInstance IDs for cards that require targeting. + The specific meaning depends on the card's effect. + additional_params: Extra parameters for complex trainer effects. + """ + + type: Literal["play_trainer"] = "play_trainer" + card_instance_id: str + targets: list[str] = Field(default_factory=list) + additional_params: dict[str, Any] = Field(default_factory=dict) + + +class UseAbilityAction(BaseModel): + """Use an ability on a Pokemon in play. + + Abilities are typically limited to once per turn per Pokemon + (configurable per ability). + + Attributes: + type: Discriminator field, always "use_ability". + pokemon_id: The CardInstance.instance_id of the Pokemon with the ability. + ability_index: Index of the ability in the Pokemon's abilities list. + targets: List of target CardInstance IDs if the ability requires targeting. + additional_params: Extra parameters for complex ability effects. + """ + + type: Literal["use_ability"] = "use_ability" + pokemon_id: str + ability_index: int = 0 + targets: list[str] = Field(default_factory=list) + additional_params: dict[str, Any] = Field(default_factory=dict) + + +class AttackAction(BaseModel): + """Declare an attack with the active Pokemon. + + This action can only be performed during the attack phase and ends the turn. + The attack's effect is resolved by the effect handler system. + + Attributes: + type: Discriminator field, always "attack". + attack_index: Index of the attack in the active Pokemon's attacks list. + targets: List of target CardInstance IDs for attacks that allow targeting. + Most attacks target the defending Pokemon automatically. + additional_params: Extra parameters for complex attack effects. + """ + + type: Literal["attack"] = "attack" + attack_index: int = 0 + targets: list[str] = Field(default_factory=list) + additional_params: dict[str, Any] = Field(default_factory=dict) + + +class RetreatAction(BaseModel): + """Retreat the active Pokemon, switching with a benched Pokemon. + + Requires discarding energy equal to the retreat cost (unless modified + by effects). Limited to once per turn by default. + + Attributes: + type: Discriminator field, always "retreat". + new_active_id: The CardInstance.instance_id of the benched Pokemon + to become the new active. + energy_to_discard: List of CardInstance IDs of energy cards to discard + as the retreat cost. + """ + + type: Literal["retreat"] = "retreat" + new_active_id: str + energy_to_discard: list[str] = Field(default_factory=list) + + +class PassAction(BaseModel): + """Pass without taking an action, ending the current phase. + + During the main phase, this advances to the attack phase. + During the attack phase, this ends the turn without attacking. + + Attributes: + type: Discriminator field, always "pass". + """ + + type: Literal["pass"] = "pass" + + +class SelectPrizeAction(BaseModel): + """Select a prize card to take after a knockout. + + Only used when using classic prize card rules (not the default point system). + + Attributes: + type: Discriminator field, always "select_prize". + prize_index: Index of the prize card to take (0-5 for 6 prizes). + """ + + type: Literal["select_prize"] = "select_prize" + prize_index: int + + +class SelectActiveAction(BaseModel): + """Select a new active Pokemon after the current active is knocked out. + + Attributes: + type: Discriminator field, always "select_active". + pokemon_id: The CardInstance.instance_id of the benched Pokemon + to become the new active. + """ + + type: Literal["select_active"] = "select_active" + pokemon_id: str + + +class ResignAction(BaseModel): + """Resign from the match, conceding victory to the opponent. + + Attributes: + type: Discriminator field, always "resign". + """ + + type: Literal["resign"] = "resign" + + +# Union type for all player actions +# Using Annotated with Field(discriminator) for automatic type resolution +Action = Annotated[ + PlayPokemonAction + | EvolvePokemonAction + | AttachEnergyAction + | PlayTrainerAction + | UseAbilityAction + | AttackAction + | RetreatAction + | PassAction + | SelectPrizeAction + | SelectActiveAction + | ResignAction, + Field(discriminator="type"), +] + + +def parse_action(data: dict[str, Any]) -> Action: + """Parse an action from a dictionary (e.g., from JSON). + + Uses the 'type' field to determine which action model to use. + + Args: + data: Dictionary containing action data with a 'type' field. + + Returns: + The appropriate Action subtype. + + Raises: + ValueError: If the action type is unknown. + ValidationError: If the data doesn't match the action schema. + + Example: + data = {"type": "attack", "attack_index": 0} + action = parse_action(data) + assert isinstance(action, AttackAction) + """ + from pydantic import TypeAdapter + + adapter = TypeAdapter(Action) + return adapter.validate_python(data) + + +# Mapping of action types to their valid phases +# This is used by the rules validator to check if an action is valid +# in the current phase +VALID_PHASES_FOR_ACTION: dict[str, list[str]] = { + "play_pokemon": ["setup", "main"], + "evolve": ["main"], + "attach_energy": ["main"], + "play_trainer": ["main"], + "use_ability": ["main"], + "attack": ["attack"], + "retreat": ["main"], + "pass": ["main", "attack"], + "select_prize": ["end"], + "select_active": ["main", "end"], # After knockout + "resign": ["setup", "draw", "main", "attack", "end"], # Any time +} diff --git a/backend/app/core/models/card.py b/backend/app/core/models/card.py new file mode 100644 index 0000000..b38944d --- /dev/null +++ b/backend/app/core/models/card.py @@ -0,0 +1,363 @@ +"""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 + +from app.core.models.enums import ( + CardType, + EnergyType, + 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. + 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 + 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. + once_per_turn: If True, can only be used once per turn per Pokemon. + activation_phase: When this ability can be activated (default: main phase). + """ + + name: str + effect_id: str + effect_params: dict[str, Any] = Field(default_factory=dict) + effect_description: str | None = None + once_per_turn: bool = True + + +class WeaknessResistance(BaseModel): + """Weakness or resistance to a specific energy type. + + Attributes: + energy_type: The energy type this applies to. + modifier: Damage modifier. For weakness, typically 2 (x2 damage). + For resistance, typically -30. + """ + + energy_type: EnergyType + modifier: int + + +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. + """ + + 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 + + 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. + + 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: List of CardInstance IDs for attached energy cards. + attached_tools: List of CardInstance IDs for attached tool cards. + status_conditions: Active status conditions on this Pokemon. + ability_used_this_turn: Whether an ability was used this turn. + evolved_from_instance_id: The CardInstance this evolved from. + 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[str] = Field(default_factory=list) + attached_tools: list[str] = Field(default_factory=list) + status_conditions: list[StatusCondition] = Field(default_factory=list) + ability_used_this_turn: bool = False + + # Evolution tracking + evolved_from_instance_id: str | None = None + turn_played: int | None = None + turn_evolved: int | None = None + + def is_knocked_out(self, hp: int) -> bool: + """Check if this Pokemon is knocked out. + + Args: + hp: The Pokemon's max HP (from CardDefinition). + + Returns: + True if damage >= hp. + """ + return self.damage >= hp + + def remaining_hp(self, hp: int) -> int: + """Calculate remaining HP. + + Args: + hp: The Pokemon's max HP (from CardDefinition). + + Returns: + Remaining HP (minimum 0). + """ + return max(0, 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 flags. Called at the start of each turn.""" + self.ability_used_this_turn = False + + 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_instance_id: str) -> None: + """Attach an energy card to this Pokemon. + + Args: + energy_instance_id: The CardInstance.instance_id of the energy card. + """ + self.attached_energy.append(energy_instance_id) + + def detach_energy(self, energy_instance_id: str) -> bool: + """Detach an energy card from this Pokemon. + + Args: + energy_instance_id: The CardInstance.instance_id of the energy card. + + Returns: + True if the energy was found and removed, False otherwise. + """ + if energy_instance_id in self.attached_energy: + self.attached_energy.remove(energy_instance_id) + return True + return False diff --git a/backend/app/core/models/enums.py b/backend/app/core/models/enums.py index 073a29d..3fb5dd4 100644 --- a/backend/app/core/models/enums.py +++ b/backend/app/core/models/enums.py @@ -30,19 +30,36 @@ class PokemonStage(StrEnum): - BASIC: Can be played directly from hand to bench - STAGE_1: Must evolve from a Basic Pokemon - STAGE_2: Must evolve from a Stage 1 Pokemon - - V: Special basic Pokemon worth 2 knockout points - - VMAX: Evolves from a V Pokemon, worth 3 knockout points - - EX: Can be basic, stage 1, or stage 2; worth 2 knockout points - - GX: Similar to EX, worth 2 knockout points + + Note: This is separate from PokemonVariant (EX, V, GX, etc.) which affects + knockout points but not evolution mechanics. """ BASIC = "basic" STAGE_1 = "stage_1" STAGE_2 = "stage_2" - V = "v" - VMAX = "vmax" + + +class PokemonVariant(StrEnum): + """Special variant classification for Pokemon cards. + + Variants affect knockout points and may have special rules, but are + orthogonal to evolution stage. A card can be a "Basic EX" or "Stage 2 GX". + + - NORMAL: Standard Pokemon, worth 1 knockout point + - EX: Worth 2 knockout points + - GX: Worth 2 knockout points, has GX attack (once per game) + - V: Worth 2 knockout points + - VMAX: Evolves from V variant, worth 3 knockout points + - VSTAR: Evolves from V variant, worth 3 knockout points, has VSTAR power + """ + + NORMAL = "normal" EX = "ex" GX = "gx" + V = "v" + VMAX = "vmax" + VSTAR = "vstar" class EnergyType(StrEnum): diff --git a/backend/tests/core/test_config.py b/backend/tests/core/test_config.py index 732ead0..ca3ea1a 100644 --- a/backend/tests/core/test_config.py +++ b/backend/tests/core/test_config.py @@ -22,7 +22,7 @@ from app.core.config import ( TrainerConfig, WinConditionsConfig, ) -from app.core.models.enums import EnergyType, PokemonStage +from app.core.models.enums import EnergyType, PokemonVariant class TestDeckConfig: @@ -126,22 +126,27 @@ class TestPrizeConfig: Verify PrizeConfig defaults match Mantimon TCG house rules. Per GAME_RULES.md: 4 points to win, points instead of prize cards. + Knockout points are based on variant (EX, V, etc.), not evolution stage. """ config = PrizeConfig() assert config.count == 4 - assert config.per_knockout_basic == 1 + assert config.per_knockout_normal == 1 assert config.per_knockout_ex == 2 + assert config.per_knockout_gx == 2 assert config.per_knockout_v == 2 assert config.per_knockout_vmax == 3 + assert config.per_knockout_vstar == 3 assert config.use_prize_cards is False - def test_points_for_knockout_basic(self) -> None: + def test_points_for_knockout_normal(self) -> None: """ - Verify points_for_knockout returns correct value for basic Pokemon. + Verify points_for_knockout returns correct value for normal Pokemon. + + Normal Pokemon (non-variant) are worth 1 point regardless of evolution stage. """ config = PrizeConfig() - assert config.points_for_knockout(PokemonStage.BASIC) == 1 + assert config.points_for_knockout(PokemonVariant.NORMAL) == 1 def test_points_for_knockout_ex(self) -> None: """ @@ -150,7 +155,7 @@ class TestPrizeConfig: EX Pokemon are worth 2 knockout points per GAME_RULES.md. """ config = PrizeConfig() - assert config.points_for_knockout(PokemonStage.EX) == 2 + assert config.points_for_knockout(PokemonVariant.EX) == 2 def test_points_for_knockout_vmax(self) -> None: """ @@ -159,29 +164,31 @@ class TestPrizeConfig: VMAX are the highest-value Pokemon in the game. """ config = PrizeConfig() - assert config.points_for_knockout(PokemonStage.VMAX) == 3 + assert config.points_for_knockout(PokemonVariant.VMAX) == 3 - def test_points_for_knockout_all_stages(self) -> None: + def test_points_for_knockout_all_variants(self) -> None: """ - Verify points_for_knockout works for all Pokemon stages. + Verify points_for_knockout works for all Pokemon variants. + + Knockout points are determined by variant, not evolution stage. + A Basic EX is worth the same as a Stage 2 EX. """ config = PrizeConfig() - assert config.points_for_knockout(PokemonStage.BASIC) == 1 - assert config.points_for_knockout(PokemonStage.STAGE_1) == 1 - assert config.points_for_knockout(PokemonStage.STAGE_2) == 1 - assert config.points_for_knockout(PokemonStage.EX) == 2 - assert config.points_for_knockout(PokemonStage.V) == 2 - assert config.points_for_knockout(PokemonStage.VMAX) == 3 - assert config.points_for_knockout(PokemonStage.GX) == 2 + assert config.points_for_knockout(PokemonVariant.NORMAL) == 1 + assert config.points_for_knockout(PokemonVariant.EX) == 2 + assert config.points_for_knockout(PokemonVariant.GX) == 2 + assert config.points_for_knockout(PokemonVariant.V) == 2 + assert config.points_for_knockout(PokemonVariant.VMAX) == 3 + assert config.points_for_knockout(PokemonVariant.VSTAR) == 3 def test_custom_knockout_points(self) -> None: """ Verify knockout point values can be customized. """ - config = PrizeConfig(per_knockout_basic=2, per_knockout_ex=3) - assert config.points_for_knockout(PokemonStage.BASIC) == 2 - assert config.points_for_knockout(PokemonStage.EX) == 3 + config = PrizeConfig(per_knockout_normal=2, per_knockout_ex=3) + assert config.points_for_knockout(PokemonVariant.NORMAL) == 2 + assert config.points_for_knockout(PokemonVariant.EX) == 3 class TestFirstTurnConfig: diff --git a/backend/tests/core/test_models/test_actions.py b/backend/tests/core/test_models/test_actions.py new file mode 100644 index 0000000..515ac2d --- /dev/null +++ b/backend/tests/core/test_models/test_actions.py @@ -0,0 +1,494 @@ +"""Tests for the action models. + +These tests verify: +1. Each action type can be created and serialized +2. The discriminated union correctly identifies action types +3. parse_action correctly parses JSON into typed actions +4. VALID_PHASES_FOR_ACTION contains all action types +""" + +import pytest +from pydantic import ValidationError + +from app.core.models.actions import ( + VALID_PHASES_FOR_ACTION, + Action, + AttachEnergyAction, + AttackAction, + EvolvePokemonAction, + PassAction, + PlayPokemonAction, + PlayTrainerAction, + ResignAction, + RetreatAction, + SelectActiveAction, + SelectPrizeAction, + UseAbilityAction, + parse_action, +) + + +class TestPlayPokemonAction: + """Tests for PlayPokemonAction.""" + + def test_basic_creation(self) -> None: + """ + Verify PlayPokemonAction can be created with minimal fields. + """ + action = PlayPokemonAction(card_instance_id="card-001") + + assert action.type == "play_pokemon" + assert action.card_instance_id == "card-001" + assert action.to_active is False + + def test_to_active_during_setup(self) -> None: + """ + Verify to_active can be set for setup phase placement. + """ + action = PlayPokemonAction(card_instance_id="card-001", to_active=True) + + assert action.to_active is True + + def test_json_round_trip(self) -> None: + """ + Verify PlayPokemonAction serializes correctly. + """ + original = PlayPokemonAction(card_instance_id="card-001", to_active=True) + json_str = original.model_dump_json() + restored = PlayPokemonAction.model_validate_json(json_str) + + assert restored.card_instance_id == original.card_instance_id + assert restored.to_active == original.to_active + + +class TestEvolvePokemonAction: + """Tests for EvolvePokemonAction.""" + + def test_basic_creation(self) -> None: + """ + Verify EvolvePokemonAction can be created. + """ + action = EvolvePokemonAction( + evolution_card_id="raichu-001", + target_pokemon_id="pikachu-001", + ) + + assert action.type == "evolve" + assert action.evolution_card_id == "raichu-001" + assert action.target_pokemon_id == "pikachu-001" + + +class TestAttachEnergyAction: + """Tests for AttachEnergyAction.""" + + def test_basic_creation(self) -> None: + """ + Verify AttachEnergyAction can be created. + """ + action = AttachEnergyAction( + energy_card_id="lightning-001", + target_pokemon_id="pikachu-001", + ) + + assert action.type == "attach_energy" + assert action.energy_card_id == "lightning-001" + assert action.target_pokemon_id == "pikachu-001" + assert action.from_energy_deck is False + + def test_from_energy_deck(self) -> None: + """ + Verify energy can come from energy deck (Pokemon Pocket style). + """ + action = AttachEnergyAction( + energy_card_id="lightning-001", + target_pokemon_id="pikachu-001", + from_energy_deck=True, + ) + + assert action.from_energy_deck is True + + +class TestPlayTrainerAction: + """Tests for PlayTrainerAction.""" + + def test_basic_creation(self) -> None: + """ + Verify PlayTrainerAction can be created. + """ + action = PlayTrainerAction(card_instance_id="potion-001") + + assert action.type == "play_trainer" + assert action.card_instance_id == "potion-001" + assert action.targets == [] + + def test_with_targets(self) -> None: + """ + Verify trainer cards can have targets. + """ + action = PlayTrainerAction( + card_instance_id="switch-001", + targets=["pokemon-001", "pokemon-002"], + ) + + assert len(action.targets) == 2 + + def test_with_additional_params(self) -> None: + """ + Verify trainer cards can have additional parameters. + """ + action = PlayTrainerAction( + card_instance_id="pokeball-001", + additional_params={"search_count": 3}, + ) + + assert action.additional_params["search_count"] == 3 + + +class TestUseAbilityAction: + """Tests for UseAbilityAction.""" + + def test_basic_creation(self) -> None: + """ + Verify UseAbilityAction can be created. + """ + action = UseAbilityAction( + pokemon_id="pikachu-001", + ability_index=0, + ) + + assert action.type == "use_ability" + assert action.pokemon_id == "pikachu-001" + assert action.ability_index == 0 + + def test_with_targets(self) -> None: + """ + Verify abilities can have targets. + """ + action = UseAbilityAction( + pokemon_id="alakazam-001", + ability_index=0, + targets=["charmander-001", "squirtle-001"], + ) + + assert len(action.targets) == 2 + + +class TestAttackAction: + """Tests for AttackAction.""" + + def test_basic_creation(self) -> None: + """ + Verify AttackAction can be created. + """ + action = AttackAction(attack_index=0) + + assert action.type == "attack" + assert action.attack_index == 0 + assert action.targets == [] + + def test_with_targets(self) -> None: + """ + Verify attacks can specify targets. + """ + action = AttackAction( + attack_index=1, + targets=["benched-pokemon-001"], + ) + + assert action.targets == ["benched-pokemon-001"] + + def test_default_attack_index(self) -> None: + """ + Verify attack_index defaults to 0. + """ + action = AttackAction() + assert action.attack_index == 0 + + +class TestRetreatAction: + """Tests for RetreatAction.""" + + def test_basic_creation(self) -> None: + """ + Verify RetreatAction can be created. + """ + action = RetreatAction(new_active_id="bulbasaur-001") + + assert action.type == "retreat" + assert action.new_active_id == "bulbasaur-001" + assert action.energy_to_discard == [] + + def test_with_energy_discard(self) -> None: + """ + Verify retreat cost energy can be specified. + """ + action = RetreatAction( + new_active_id="bulbasaur-001", + energy_to_discard=["energy-001", "energy-002"], + ) + + assert len(action.energy_to_discard) == 2 + + +class TestPassAction: + """Tests for PassAction.""" + + def test_basic_creation(self) -> None: + """ + Verify PassAction can be created. + """ + action = PassAction() + + assert action.type == "pass" + + +class TestSelectPrizeAction: + """Tests for SelectPrizeAction.""" + + def test_basic_creation(self) -> None: + """ + Verify SelectPrizeAction can be created. + """ + action = SelectPrizeAction(prize_index=2) + + assert action.type == "select_prize" + assert action.prize_index == 2 + + +class TestSelectActiveAction: + """Tests for SelectActiveAction.""" + + def test_basic_creation(self) -> None: + """ + Verify SelectActiveAction can be created. + """ + action = SelectActiveAction(pokemon_id="bulbasaur-001") + + assert action.type == "select_active" + assert action.pokemon_id == "bulbasaur-001" + + +class TestResignAction: + """Tests for ResignAction.""" + + def test_basic_creation(self) -> None: + """ + Verify ResignAction can be created. + """ + action = ResignAction() + + assert action.type == "resign" + + +class TestActionUnion: + """Tests for the Action discriminated union.""" + + def test_parse_play_pokemon(self) -> None: + """ + Verify parse_action correctly identifies PlayPokemonAction. + """ + data = {"type": "play_pokemon", "card_instance_id": "card-001"} + action = parse_action(data) + + assert isinstance(action, PlayPokemonAction) + assert action.card_instance_id == "card-001" + + def test_parse_evolve(self) -> None: + """ + Verify parse_action correctly identifies EvolvePokemonAction. + """ + data = { + "type": "evolve", + "evolution_card_id": "raichu-001", + "target_pokemon_id": "pikachu-001", + } + action = parse_action(data) + + assert isinstance(action, EvolvePokemonAction) + + def test_parse_attach_energy(self) -> None: + """ + Verify parse_action correctly identifies AttachEnergyAction. + """ + data = { + "type": "attach_energy", + "energy_card_id": "lightning-001", + "target_pokemon_id": "pikachu-001", + } + action = parse_action(data) + + assert isinstance(action, AttachEnergyAction) + + def test_parse_play_trainer(self) -> None: + """ + Verify parse_action correctly identifies PlayTrainerAction. + """ + data = {"type": "play_trainer", "card_instance_id": "potion-001"} + action = parse_action(data) + + assert isinstance(action, PlayTrainerAction) + + def test_parse_use_ability(self) -> None: + """ + Verify parse_action correctly identifies UseAbilityAction. + """ + data = {"type": "use_ability", "pokemon_id": "alakazam-001"} + action = parse_action(data) + + assert isinstance(action, UseAbilityAction) + + def test_parse_attack(self) -> None: + """ + Verify parse_action correctly identifies AttackAction. + """ + data = {"type": "attack", "attack_index": 1} + action = parse_action(data) + + assert isinstance(action, AttackAction) + assert action.attack_index == 1 + + def test_parse_retreat(self) -> None: + """ + Verify parse_action correctly identifies RetreatAction. + """ + data = {"type": "retreat", "new_active_id": "bulbasaur-001"} + action = parse_action(data) + + assert isinstance(action, RetreatAction) + + def test_parse_pass(self) -> None: + """ + Verify parse_action correctly identifies PassAction. + """ + data = {"type": "pass"} + action = parse_action(data) + + assert isinstance(action, PassAction) + + def test_parse_select_prize(self) -> None: + """ + Verify parse_action correctly identifies SelectPrizeAction. + """ + data = {"type": "select_prize", "prize_index": 3} + action = parse_action(data) + + assert isinstance(action, SelectPrizeAction) + + def test_parse_select_active(self) -> None: + """ + Verify parse_action correctly identifies SelectActiveAction. + """ + data = {"type": "select_active", "pokemon_id": "squirtle-001"} + action = parse_action(data) + + assert isinstance(action, SelectActiveAction) + + def test_parse_resign(self) -> None: + """ + Verify parse_action correctly identifies ResignAction. + """ + data = {"type": "resign"} + action = parse_action(data) + + assert isinstance(action, ResignAction) + + def test_parse_unknown_type_raises(self) -> None: + """ + Verify parse_action raises an error for unknown action types. + """ + data = {"type": "unknown_action"} + + with pytest.raises(ValidationError): + parse_action(data) + + def test_parse_missing_required_field_raises(self) -> None: + """ + Verify parse_action raises an error when required fields are missing. + """ + data = {"type": "play_pokemon"} # Missing card_instance_id + + with pytest.raises(ValidationError): + parse_action(data) + + +class TestActionJsonSerialization: + """Tests for JSON serialization of all action types.""" + + @pytest.mark.parametrize( + "action", + [ + PlayPokemonAction(card_instance_id="card-001"), + EvolvePokemonAction(evolution_card_id="evo-001", target_pokemon_id="base-001"), + AttachEnergyAction(energy_card_id="energy-001", target_pokemon_id="pokemon-001"), + PlayTrainerAction(card_instance_id="trainer-001"), + UseAbilityAction(pokemon_id="pokemon-001"), + AttackAction(attack_index=0), + RetreatAction(new_active_id="bench-001"), + PassAction(), + SelectPrizeAction(prize_index=0), + SelectActiveAction(pokemon_id="bench-001"), + ResignAction(), + ], + ) + def test_json_round_trip(self, action: Action) -> None: + """ + Verify all action types round-trip through JSON correctly. + """ + # Test JSON serialization via dict (what websocket messages use) + data = action.model_dump() + restored = parse_action(data) + + assert type(restored) is type(action) + assert restored.type == action.type + + +class TestValidPhasesForAction: + """Tests for the VALID_PHASES_FOR_ACTION mapping.""" + + def test_all_action_types_have_valid_phases(self) -> None: + """ + Verify every action type has a valid phases entry. + """ + action_types = [ + "play_pokemon", + "evolve", + "attach_energy", + "play_trainer", + "use_ability", + "attack", + "retreat", + "pass", + "select_prize", + "select_active", + "resign", + ] + + for action_type in action_types: + assert action_type in VALID_PHASES_FOR_ACTION, f"Missing: {action_type}" + assert len(VALID_PHASES_FOR_ACTION[action_type]) > 0 + + def test_attack_only_in_attack_phase(self) -> None: + """ + Verify attack is only valid during the attack phase. + """ + assert VALID_PHASES_FOR_ACTION["attack"] == ["attack"] + + def test_resign_valid_any_phase(self) -> None: + """ + Verify resign can be done at any time. + """ + phases = VALID_PHASES_FOR_ACTION["resign"] + assert "setup" in phases + assert "draw" in phases + assert "main" in phases + assert "attack" in phases + assert "end" in phases + + def test_pass_valid_in_main_and_attack(self) -> None: + """ + Verify pass is valid in main phase (to skip to attack) and + attack phase (to end turn without attacking). + """ + phases = VALID_PHASES_FOR_ACTION["pass"] + assert "main" in phases + assert "attack" in phases diff --git a/backend/tests/core/test_models/test_card.py b/backend/tests/core/test_models/test_card.py new file mode 100644 index 0000000..87e666c --- /dev/null +++ b/backend/tests/core/test_models/test_card.py @@ -0,0 +1,1014 @@ +"""Tests for the card models (CardDefinition and CardInstance). + +These tests verify: +1. CardDefinition correctly represents all card types +2. CardInstance properly tracks mutable state +3. Helper methods work correctly +4. Status condition logic follows game rules +5. JSON serialization round-trips work +""" + +from app.core.models.card import ( + Ability, + Attack, + CardDefinition, + CardInstance, + WeaknessResistance, +) +from app.core.models.enums import ( + CardType, + EnergyType, + PokemonStage, + PokemonVariant, + StatusCondition, + TrainerType, +) + + +class TestAttack: + """Tests for the Attack model.""" + + def test_basic_attack(self) -> None: + """ + Verify a basic attack can be created with minimal fields. + """ + attack = Attack(name="Tackle", damage=10) + + assert attack.name == "Tackle" + assert attack.damage == 10 + assert attack.cost == [] + assert attack.effect_id is None + + def test_attack_with_energy_cost(self) -> None: + """ + Verify attacks can have energy costs. + """ + attack = Attack( + name="Thunder Shock", + cost=[EnergyType.LIGHTNING], + damage=20, + ) + + assert attack.cost == [EnergyType.LIGHTNING] + + def test_attack_with_colorless_cost(self) -> None: + """ + Verify attacks can have colorless energy requirements. + + Colorless can be satisfied by any energy type. + """ + attack = Attack( + name="Slash", + cost=[EnergyType.COLORLESS, EnergyType.COLORLESS], + damage=30, + ) + + assert len(attack.cost) == 2 + assert all(c == EnergyType.COLORLESS for c in attack.cost) + + def test_attack_with_effect(self) -> None: + """ + Verify attacks can reference effect handlers. + """ + attack = Attack( + name="Thunder Shock", + cost=[EnergyType.LIGHTNING], + damage=20, + effect_id="may_paralyze", + effect_params={"chance": 0.5}, + effect_description="Flip a coin. If heads, the Defending Pokemon is now Paralyzed.", + ) + + assert attack.effect_id == "may_paralyze" + assert attack.effect_params["chance"] == 0.5 + + def test_attack_json_round_trip(self) -> None: + """ + Verify Attack serializes and deserializes correctly. + """ + original = Attack( + name="Thunderbolt", + cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.COLORLESS], + damage=100, + effect_id="discard_all_energy", + ) + + json_str = original.model_dump_json() + restored = Attack.model_validate_json(json_str) + + assert restored.name == original.name + assert restored.cost == original.cost + assert restored.damage == original.damage + assert restored.effect_id == original.effect_id + + +class TestAbility: + """Tests for the Ability model.""" + + def test_basic_ability(self) -> None: + """ + Verify an ability can be created. + """ + ability = Ability( + name="Rain Dish", + effect_id="heal_between_turns", + effect_params={"amount": 10}, + ) + + assert ability.name == "Rain Dish" + assert ability.effect_id == "heal_between_turns" + assert ability.once_per_turn is True # Default + + def test_ability_multiple_use_per_turn(self) -> None: + """ + Verify abilities can be marked as usable multiple times per turn. + """ + ability = Ability( + name="Energy Transfer", + effect_id="move_energy", + once_per_turn=False, + ) + + assert ability.once_per_turn is False + + +class TestWeaknessResistance: + """Tests for the WeaknessResistance model.""" + + def test_weakness(self) -> None: + """ + Verify weakness can be defined. + """ + weakness = WeaknessResistance( + energy_type=EnergyType.FIGHTING, + modifier=2, + ) + + assert weakness.energy_type == EnergyType.FIGHTING + assert weakness.modifier == 2 + + def test_resistance(self) -> None: + """ + Verify resistance can be defined. + """ + resistance = WeaknessResistance( + energy_type=EnergyType.METAL, + modifier=-30, + ) + + assert resistance.energy_type == EnergyType.METAL + assert resistance.modifier == -30 + + +class TestCardDefinitionPokemon: + """Tests for Pokemon CardDefinitions.""" + + def test_basic_pokemon(self) -> None: + """ + Verify a basic Pokemon can be created with all relevant fields. + + Basic Pokemon can be played directly from hand to bench. + Default variant is NORMAL (worth 1 knockout point). + """ + pikachu = CardDefinition( + id="pikachu_base_001", + name="Pikachu", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + hp=60, + pokemon_type=EnergyType.LIGHTNING, + attacks=[ + Attack(name="Gnaw", damage=10), + Attack( + name="Thunder Shock", + cost=[EnergyType.LIGHTNING], + damage=20, + effect_id="may_paralyze", + ), + ], + weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2), + retreat_cost=1, + ) + + assert pikachu.id == "pikachu_base_001" + assert pikachu.name == "Pikachu" + assert pikachu.is_pokemon() is True + assert pikachu.is_basic_pokemon() is True + assert pikachu.is_evolution() is False + assert pikachu.hp == 60 + assert len(pikachu.attacks) == 2 + assert pikachu.variant == PokemonVariant.NORMAL + assert pikachu.knockout_points() == 1 + + def test_stage_1_pokemon(self) -> None: + """ + Verify a Stage 1 Pokemon can be created. + + Stage 1 Pokemon must evolve from a Basic Pokemon. + """ + raichu = CardDefinition( + id="raichu_base_001", + name="Raichu", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_1, + evolves_from="Pikachu", + hp=90, + pokemon_type=EnergyType.LIGHTNING, + attacks=[Attack(name="Thunder", cost=[EnergyType.LIGHTNING] * 3, damage=60)], + ) + + assert raichu.stage == PokemonStage.STAGE_1 + assert raichu.evolves_from == "Pikachu" + assert raichu.is_evolution() is True + assert raichu.is_basic_pokemon() is False + assert raichu.variant == PokemonVariant.NORMAL + assert raichu.knockout_points() == 1 + + def test_stage_2_pokemon(self) -> None: + """ + Verify a Stage 2 Pokemon can be created. + + Stage 2 Pokemon must evolve from a Stage 1 Pokemon. + """ + charizard = CardDefinition( + id="charizard_base_001", + name="Charizard", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_2, + evolves_from="Charmeleon", + hp=120, + pokemon_type=EnergyType.FIRE, + ) + + assert charizard.stage == PokemonStage.STAGE_2 + assert charizard.is_evolution() is True + assert charizard.variant == PokemonVariant.NORMAL + assert charizard.knockout_points() == 1 + + def test_basic_v_pokemon(self) -> None: + """ + Verify a Basic V Pokemon can be created. + + V is a variant, not a stage. V Pokemon can be any stage but are typically Basic. + V Pokemon are worth 2 knockout points. + """ + pikachu_v = CardDefinition( + id="pikachu_v_001", + name="Pikachu V", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.V, + hp=190, + pokemon_type=EnergyType.LIGHTNING, + ) + + assert pikachu_v.stage == PokemonStage.BASIC + assert pikachu_v.variant == PokemonVariant.V + assert pikachu_v.is_basic_pokemon() is True + assert pikachu_v.is_evolution() is False + assert pikachu_v.knockout_points() == 2 + + def test_basic_vmax_pokemon(self) -> None: + """ + Verify a VMAX Pokemon requires evolution from V. + + VMAX always evolves from a V variant. The stage is still BASIC + (since V is typically Basic), but the VMAX variant indicates + it must evolve from a V Pokemon. + """ + pikachu_vmax = CardDefinition( + id="pikachu_vmax_001", + name="Pikachu VMAX", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.VMAX, + evolves_from="Pikachu V", + hp=310, + pokemon_type=EnergyType.LIGHTNING, + ) + + assert pikachu_vmax.stage == PokemonStage.BASIC + assert pikachu_vmax.variant == PokemonVariant.VMAX + # VMAX is technically BASIC stage but requires evolution from V + assert pikachu_vmax.is_basic_pokemon() is True + assert pikachu_vmax.requires_evolution_from_variant() is True + assert pikachu_vmax.knockout_points() == 3 + + def test_basic_vstar_pokemon(self) -> None: + """ + Verify a VSTAR Pokemon requires evolution from V. + + VSTAR always evolves from a V variant, worth 2 knockout points. + """ + pikachu_vstar = CardDefinition( + id="pikachu_vstar_001", + name="Pikachu VSTAR", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.VSTAR, + evolves_from="Pikachu V", + hp=280, + pokemon_type=EnergyType.LIGHTNING, + ) + + assert pikachu_vstar.variant == PokemonVariant.VSTAR + assert pikachu_vstar.requires_evolution_from_variant() is True + assert pikachu_vstar.knockout_points() == 3 + + def test_basic_ex_pokemon(self) -> None: + """ + Verify a Basic EX Pokemon can be created. + + EX is a variant, not a stage. Basic EX Pokemon can be played + directly and are worth 2 knockout points. + """ + mewtwo_ex = CardDefinition( + id="mewtwo_ex_001", + name="Mewtwo EX", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.EX, + hp=170, + pokemon_type=EnergyType.PSYCHIC, + ) + + assert mewtwo_ex.stage == PokemonStage.BASIC + assert mewtwo_ex.variant == PokemonVariant.EX + assert mewtwo_ex.is_basic_pokemon() is True + assert mewtwo_ex.knockout_points() == 2 + + def test_stage_2_ex_pokemon(self) -> None: + """ + Verify a Stage 2 EX Pokemon can be created. + + Stage and variant are orthogonal. A Stage 2 EX must evolve from + a Stage 1 and is worth 2 knockout points due to being EX. + """ + charizard_ex = CardDefinition( + id="charizard_ex_001", + name="Charizard EX", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_2, + variant=PokemonVariant.EX, + evolves_from="Charmeleon", + hp=250, + pokemon_type=EnergyType.FIRE, + ) + + assert charizard_ex.stage == PokemonStage.STAGE_2 + assert charizard_ex.variant == PokemonVariant.EX + assert charizard_ex.is_basic_pokemon() is False + assert charizard_ex.is_evolution() is True + assert charizard_ex.knockout_points() == 2 + + def test_basic_gx_pokemon(self) -> None: + """ + Verify a Basic GX Pokemon can be created. + + GX is a variant. Basic GX can be played directly and are worth + 2 knockout points. + """ + tapu_lele_gx = CardDefinition( + id="tapu_lele_gx_001", + name="Tapu Lele GX", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.GX, + hp=170, + pokemon_type=EnergyType.PSYCHIC, + ) + + assert tapu_lele_gx.stage == PokemonStage.BASIC + assert tapu_lele_gx.variant == PokemonVariant.GX + assert tapu_lele_gx.is_basic_pokemon() is True + assert tapu_lele_gx.knockout_points() == 2 + + def test_stage_1_gx_pokemon(self) -> None: + """ + Verify a Stage 1 GX Pokemon can be created. + + Stage 1 GX must evolve from a Basic and are worth 2 points. + """ + raichu_gx = CardDefinition( + id="raichu_gx_001", + name="Raichu GX", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_1, + variant=PokemonVariant.GX, + evolves_from="Pikachu", + hp=210, + pokemon_type=EnergyType.LIGHTNING, + ) + + assert raichu_gx.stage == PokemonStage.STAGE_1 + assert raichu_gx.variant == PokemonVariant.GX + assert raichu_gx.is_basic_pokemon() is False + assert raichu_gx.is_evolution() is True + assert raichu_gx.evolves_from == "Pikachu" + assert raichu_gx.knockout_points() == 2 + + def test_knockout_points_for_all_variants(self) -> None: + """ + Verify knockout_points returns correct values for all variants. + + Knockout points are determined by variant, not stage. + """ + variants_and_points = [ + (PokemonVariant.NORMAL, 1), + (PokemonVariant.EX, 2), + (PokemonVariant.GX, 2), + (PokemonVariant.V, 2), + (PokemonVariant.VMAX, 3), + (PokemonVariant.VSTAR, 3), + ] + + for variant, expected_points in variants_and_points: + card = CardDefinition( + id=f"test_{variant.value}", + name="Test Pokemon", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=variant, + hp=100, + ) + assert card.knockout_points() == expected_points, f"Failed for {variant}" + + def test_requires_evolution_from_variant(self) -> None: + """ + Verify requires_evolution_from_variant for VMAX and VSTAR. + + VMAX and VSTAR must evolve from V Pokemon, regardless of evolution stage. + """ + # VMAX requires evolution from V + vmax = CardDefinition( + id="test_vmax", + name="Test VMAX", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.VMAX, + hp=300, + ) + assert vmax.requires_evolution_from_variant() is True + + # VSTAR requires evolution from V + vstar = CardDefinition( + id="test_vstar", + name="Test VSTAR", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.VSTAR, + hp=250, + ) + assert vstar.requires_evolution_from_variant() is True + + # Other variants don't require V evolution + for variant in [ + PokemonVariant.NORMAL, + PokemonVariant.EX, + PokemonVariant.GX, + PokemonVariant.V, + ]: + card = CardDefinition( + id=f"test_{variant.value}", + name="Test", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=variant, + hp=100, + ) + assert card.requires_evolution_from_variant() is False, f"Failed for {variant}" + + def test_stage_determines_playability(self) -> None: + """ + Verify that stage (not variant) determines direct playability. + + is_basic_pokemon checks only the stage, not the variant. + """ + # Basic stage = playable directly + basic_normal = CardDefinition( + id="test", + name="Test", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.NORMAL, + hp=50, + ) + basic_ex = CardDefinition( + id="test", + name="Test", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.EX, + hp=150, + ) + assert basic_normal.is_basic_pokemon() is True + assert basic_ex.is_basic_pokemon() is True + + # Stage 1 = must evolve + stage1_normal = CardDefinition( + id="test", + name="Test", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_1, + variant=PokemonVariant.NORMAL, + hp=80, + ) + stage1_gx = CardDefinition( + id="test", + name="Test", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_1, + variant=PokemonVariant.GX, + hp=200, + ) + assert stage1_normal.is_basic_pokemon() is False + assert stage1_gx.is_basic_pokemon() is False + + def test_default_variant_is_normal(self) -> None: + """ + Verify that Pokemon without explicit variant default to NORMAL. + """ + pokemon = CardDefinition( + id="test", + name="Test Pokemon", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + hp=50, + ) + assert pokemon.variant == PokemonVariant.NORMAL + + +class TestCardDefinitionTrainer: + """Tests for Trainer CardDefinitions.""" + + def test_item_card(self) -> None: + """ + Verify an Item trainer card can be created. + """ + potion = CardDefinition( + id="potion_base_001", + name="Potion", + card_type=CardType.TRAINER, + trainer_type=TrainerType.ITEM, + effect_id="heal", + effect_params={"amount": 30}, + effect_description="Heal 30 damage from one of your Pokemon.", + ) + + assert potion.is_trainer() is True + assert potion.trainer_type == TrainerType.ITEM + assert potion.effect_id == "heal" + + def test_supporter_card(self) -> None: + """ + Verify a Supporter trainer card can be created. + """ + professor = CardDefinition( + id="professor_oak_001", + name="Professor Oak", + card_type=CardType.TRAINER, + trainer_type=TrainerType.SUPPORTER, + effect_id="discard_hand_draw", + effect_params={"draw_count": 7}, + ) + + assert professor.trainer_type == TrainerType.SUPPORTER + + def test_stadium_card(self) -> None: + """ + Verify a Stadium trainer card can be created. + """ + stadium = CardDefinition( + id="pokemon_center_001", + name="Pokemon Center", + card_type=CardType.TRAINER, + trainer_type=TrainerType.STADIUM, + effect_id="stadium_heal_between_turns", + ) + + assert stadium.trainer_type == TrainerType.STADIUM + + def test_tool_card(self) -> None: + """ + Verify a Tool trainer card can be created. + """ + tool = CardDefinition( + id="lucky_egg_001", + name="Lucky Egg", + card_type=CardType.TRAINER, + trainer_type=TrainerType.TOOL, + effect_id="draw_on_knockout", + ) + + assert tool.trainer_type == TrainerType.TOOL + + +class TestCardDefinitionEnergy: + """Tests for Energy CardDefinitions.""" + + def test_basic_energy(self) -> None: + """ + Verify a basic energy card can be created. + """ + fire_energy = CardDefinition( + id="fire_energy_001", + name="Fire Energy", + card_type=CardType.ENERGY, + energy_type=EnergyType.FIRE, + energy_provides=[EnergyType.FIRE], + ) + + assert fire_energy.is_energy() is True + assert fire_energy.energy_type == EnergyType.FIRE + assert fire_energy.energy_provides == [EnergyType.FIRE] + + def test_special_energy(self) -> None: + """ + Verify a special energy card providing multiple types. + """ + rainbow = CardDefinition( + id="rainbow_energy_001", + name="Rainbow Energy", + card_type=CardType.ENERGY, + energy_provides=list(EnergyType), # Provides all types + effect_id="rainbow_damage", + effect_params={"damage_on_attach": 10}, + ) + + assert len(rainbow.energy_provides) == len(EnergyType) + assert rainbow.effect_id == "rainbow_damage" + + +class TestCardDefinitionHelpers: + """Tests for CardDefinition helper methods.""" + + def test_is_pokemon(self) -> None: + """Verify is_pokemon() returns correct value.""" + pokemon = CardDefinition( + id="test", name="Test", card_type=CardType.POKEMON, stage=PokemonStage.BASIC + ) + trainer = CardDefinition( + id="test", name="Test", card_type=CardType.TRAINER, trainer_type=TrainerType.ITEM + ) + energy = CardDefinition(id="test", name="Test", card_type=CardType.ENERGY) + + assert pokemon.is_pokemon() is True + assert trainer.is_pokemon() is False + assert energy.is_pokemon() is False + + def test_is_trainer(self) -> None: + """Verify is_trainer() returns correct value.""" + pokemon = CardDefinition( + id="test", name="Test", card_type=CardType.POKEMON, stage=PokemonStage.BASIC + ) + trainer = CardDefinition( + id="test", name="Test", card_type=CardType.TRAINER, trainer_type=TrainerType.ITEM + ) + + assert pokemon.is_trainer() is False + assert trainer.is_trainer() is True + + def test_is_energy(self) -> None: + """Verify is_energy() returns correct value.""" + energy = CardDefinition(id="test", name="Test", card_type=CardType.ENERGY) + + assert energy.is_energy() is True + + +class TestCardDefinitionJsonRoundTrip: + """Tests for JSON serialization of CardDefinition.""" + + def test_pokemon_round_trip(self) -> None: + """ + Verify a complex Pokemon definition round-trips through JSON. + """ + original = 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), + ], + abilities=[ + Ability(name="Static", effect_id="static_paralysis"), + ], + weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2), + resistance=WeaknessResistance(energy_type=EnergyType.METAL, modifier=-30), + retreat_cost=1, + rarity="common", + set_id="base", + ) + + json_str = original.model_dump_json() + restored = CardDefinition.model_validate_json(json_str) + + assert restored.id == original.id + assert restored.name == original.name + assert restored.hp == original.hp + assert len(restored.attacks) == 1 + assert restored.attacks[0].name == "Thunder Shock" + assert restored.weakness is not None + assert restored.weakness.energy_type == EnergyType.FIGHTING + + +class TestCardInstance: + """Tests for CardInstance.""" + + def test_basic_instance(self) -> None: + """ + Verify a card instance can be created with minimal fields. + """ + instance = CardInstance( + instance_id="uuid-12345", + definition_id="pikachu_base_001", + ) + + assert instance.instance_id == "uuid-12345" + assert instance.definition_id == "pikachu_base_001" + assert instance.damage == 0 + assert instance.attached_energy == [] + assert instance.status_conditions == [] + + def test_damage_tracking(self) -> None: + """ + Verify damage can be tracked on a card instance. + """ + instance = CardInstance( + instance_id="uuid-12345", + definition_id="pikachu_base_001", + ) + + instance.damage = 30 + assert instance.damage == 30 + + instance.damage += 20 + assert instance.damage == 50 + + def test_is_knocked_out(self) -> None: + """ + Verify knockout detection works correctly. + """ + instance = CardInstance( + instance_id="uuid-12345", + definition_id="pikachu_base_001", + ) + + hp = 60 + instance.damage = 50 + assert instance.is_knocked_out(hp) is False + + instance.damage = 60 + assert instance.is_knocked_out(hp) is True + + instance.damage = 100 # Overkill + assert instance.is_knocked_out(hp) is True + + def test_remaining_hp(self) -> None: + """ + Verify remaining HP calculation. + """ + instance = CardInstance( + instance_id="uuid-12345", + definition_id="pikachu_base_001", + ) + + hp = 60 + assert instance.remaining_hp(hp) == 60 + + instance.damage = 30 + assert instance.remaining_hp(hp) == 30 + + instance.damage = 100 + assert instance.remaining_hp(hp) == 0 # Minimum 0 + + +class TestCardInstanceStatusConditions: + """Tests for status condition handling on CardInstance.""" + + def test_add_poison(self) -> None: + """ + Verify Poison can be added. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.POISONED) + + assert instance.has_status(StatusCondition.POISONED) + + def test_add_burn(self) -> None: + """ + Verify Burn can be added and stacks with Poison. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.POISONED) + instance.add_status(StatusCondition.BURNED) + + assert instance.has_status(StatusCondition.POISONED) + assert instance.has_status(StatusCondition.BURNED) + assert len(instance.status_conditions) == 2 + + def test_asleep_overrides_paralyzed(self) -> None: + """ + Verify Asleep overrides Paralyzed. + + Per GAME_RULES.md, Asleep/Paralyzed/Confused override each other. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.PARALYZED) + assert instance.has_status(StatusCondition.PARALYZED) + + instance.add_status(StatusCondition.ASLEEP) + assert instance.has_status(StatusCondition.ASLEEP) + assert not instance.has_status(StatusCondition.PARALYZED) + assert len(instance.status_conditions) == 1 + + def test_paralyzed_overrides_asleep(self) -> None: + """ + Verify Paralyzed overrides Asleep. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.ASLEEP) + instance.add_status(StatusCondition.PARALYZED) + + assert instance.has_status(StatusCondition.PARALYZED) + assert not instance.has_status(StatusCondition.ASLEEP) + + def test_confused_overrides_asleep(self) -> None: + """ + Verify Confused overrides Asleep. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.ASLEEP) + instance.add_status(StatusCondition.CONFUSED) + + assert instance.has_status(StatusCondition.CONFUSED) + assert not instance.has_status(StatusCondition.ASLEEP) + + def test_poison_stacks_with_overriding_conditions(self) -> None: + """ + Verify Poison stacks with Asleep/Paralyzed/Confused. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.POISONED) + instance.add_status(StatusCondition.ASLEEP) + + assert instance.has_status(StatusCondition.POISONED) + assert instance.has_status(StatusCondition.ASLEEP) + assert len(instance.status_conditions) == 2 + + def test_remove_status(self) -> None: + """ + Verify status conditions can be removed. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.POISONED) + instance.remove_status(StatusCondition.POISONED) + + assert not instance.has_status(StatusCondition.POISONED) + + def test_clear_all_status(self) -> None: + """ + Verify all status conditions can be cleared (e.g., on retreat/evolve). + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.POISONED) + instance.add_status(StatusCondition.BURNED) + instance.add_status(StatusCondition.ASLEEP) + + instance.clear_all_status() + + assert len(instance.status_conditions) == 0 + + def test_duplicate_status_not_added(self) -> None: + """ + Verify the same status isn't added twice. + """ + instance = CardInstance(instance_id="uuid", definition_id="test") + instance.add_status(StatusCondition.POISONED) + instance.add_status(StatusCondition.POISONED) + + assert instance.status_conditions.count(StatusCondition.POISONED) == 1 + + +class TestCardInstanceEvolution: + """Tests for evolution-related CardInstance methods.""" + + def test_can_evolve_after_one_turn(self) -> None: + """ + Verify a Pokemon can evolve after being in play for a turn. + """ + instance = CardInstance( + instance_id="uuid", + definition_id="pikachu", + turn_played=1, + ) + + assert instance.can_evolve_this_turn(2) is True + + def test_cannot_evolve_same_turn_played(self) -> None: + """ + Verify a Pokemon cannot evolve the same turn it was played. + """ + instance = CardInstance( + instance_id="uuid", + definition_id="pikachu", + turn_played=1, + ) + + assert instance.can_evolve_this_turn(1) is False + + def test_cannot_evolve_same_turn_as_evolution(self) -> None: + """ + Verify a Pokemon cannot evolve again the same turn it evolved. + """ + instance = CardInstance( + instance_id="uuid", + definition_id="charmeleon", + turn_played=1, + turn_evolved=3, + ) + + assert instance.can_evolve_this_turn(3) is False + assert instance.can_evolve_this_turn(4) is True + + +class TestCardInstanceEnergy: + """Tests for energy attachment on CardInstance.""" + + def test_attach_energy(self) -> None: + """ + Verify energy can be attached to a Pokemon. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + + instance.attach_energy("energy-001") + instance.attach_energy("energy-002") + + assert instance.energy_count() == 2 + assert "energy-001" in instance.attached_energy + + def test_detach_energy(self) -> None: + """ + Verify energy can be detached from a Pokemon. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + instance.attach_energy("energy-001") + instance.attach_energy("energy-002") + + result = instance.detach_energy("energy-001") + + assert result is True + assert instance.energy_count() == 1 + assert "energy-001" not in instance.attached_energy + + def test_detach_nonexistent_energy(self) -> None: + """ + Verify detaching non-existent energy returns False. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + + result = instance.detach_energy("nonexistent") + + assert result is False + + +class TestCardInstanceTurnState: + """Tests for per-turn state on CardInstance.""" + + def test_reset_turn_state(self) -> None: + """ + Verify reset_turn_state clears ability usage flag. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + instance.ability_used_this_turn = True + + instance.reset_turn_state() + + assert instance.ability_used_this_turn is False + + +class TestCardInstanceJsonRoundTrip: + """Tests for JSON serialization of CardInstance.""" + + def test_round_trip(self) -> None: + """ + Verify CardInstance round-trips through JSON. + """ + original = CardInstance( + instance_id="uuid-12345", + definition_id="pikachu_base_001", + damage=30, + attached_energy=["energy-001", "energy-002"], + status_conditions=[StatusCondition.POISONED], + turn_played=2, + ) + + json_str = original.model_dump_json() + restored = CardInstance.model_validate_json(json_str) + + assert restored.instance_id == original.instance_id + assert restored.damage == 30 + assert len(restored.attached_energy) == 2 + assert StatusCondition.POISONED in restored.status_conditions diff --git a/backend/tests/core/test_models/test_enums.py b/backend/tests/core/test_models/test_enums.py index 2ff2524..136b38a 100644 --- a/backend/tests/core/test_models/test_enums.py +++ b/backend/tests/core/test_models/test_enums.py @@ -18,6 +18,7 @@ from app.core.models.enums import ( EnergyType, GameEndReason, PokemonStage, + PokemonVariant, StatusCondition, TrainerType, TurnPhase, @@ -75,19 +76,19 @@ class TestPokemonStage: def test_pokemon_stage_values(self) -> None: """ - Verify PokemonStage has all expected evolution stages. + Verify PokemonStage has the three evolution stages only. - Includes both classic stages (basic, stage 1, stage 2) and modern - variants (V, VMAX, EX, GX). + Evolution stages determine how a Pokemon can be played: + - BASIC: Can be played directly from hand + - STAGE_1: Must evolve from a Basic + - STAGE_2: Must evolve from a Stage 1 + + Note: Variants (EX, V, GX, etc.) are now in PokemonVariant enum. """ assert PokemonStage.BASIC == "basic" assert PokemonStage.STAGE_1 == "stage_1" assert PokemonStage.STAGE_2 == "stage_2" - assert PokemonStage.V == "v" - assert PokemonStage.VMAX == "vmax" - assert PokemonStage.EX == "ex" - assert PokemonStage.GX == "gx" - assert len(PokemonStage) == 7 + assert len(PokemonStage) == 3 def test_pokemon_stage_membership(self) -> None: """ @@ -97,6 +98,69 @@ class TestPokemonStage: """ assert "basic" in [s.value for s in PokemonStage] assert "invalid" not in [s.value for s in PokemonStage] + # Variants are NOT in PokemonStage anymore + assert "ex" not in [s.value for s in PokemonStage] + assert "v" not in [s.value for s in PokemonStage] + + +class TestPokemonVariant: + """Tests for the PokemonVariant enum.""" + + def test_pokemon_variant_values(self) -> None: + """ + Verify PokemonVariant has all expected variant classifications. + + Variants affect knockout points but are orthogonal to evolution stage. + A card can be "Basic EX" or "Stage 2 GX". + """ + assert PokemonVariant.NORMAL == "normal" + assert PokemonVariant.EX == "ex" + assert PokemonVariant.GX == "gx" + assert PokemonVariant.V == "v" + assert PokemonVariant.VMAX == "vmax" + assert PokemonVariant.VSTAR == "vstar" + assert len(PokemonVariant) == 6 + + def test_pokemon_variant_membership(self) -> None: + """ + Verify membership checks work for PokemonVariant. + + This is used to validate card definitions and calculate knockout points. + """ + assert "normal" in [v.value for v in PokemonVariant] + assert "ex" in [v.value for v in PokemonVariant] + assert "invalid" not in [v.value for v in PokemonVariant] + + def test_variant_knockout_point_categories(self) -> None: + """ + Document which variants are worth more knockout points. + + - NORMAL: 1 point (standard Pokemon) + - EX, GX, V: 2 points + - VMAX, VSTAR: 3 points + """ + one_point_variants = {PokemonVariant.NORMAL} + two_point_variants = { + PokemonVariant.EX, + PokemonVariant.GX, + PokemonVariant.V, + } + three_point_variants = {PokemonVariant.VMAX, PokemonVariant.VSTAR} + + # Verify all variants are accounted for + all_variants = set(PokemonVariant) + assert one_point_variants | two_point_variants | three_point_variants == all_variants + + def test_variants_requiring_v_evolution(self) -> None: + """ + Document which variants must evolve from a V Pokemon. + + VMAX and VSTAR are special - they always evolve from a V variant, + regardless of the base Pokemon's evolution stage. + """ + v_evolution_variants = {PokemonVariant.VMAX, PokemonVariant.VSTAR} + assert PokemonVariant.VMAX in v_evolution_variants + assert PokemonVariant.VSTAR in v_evolution_variants class TestEnergyType: @@ -275,6 +339,7 @@ class TestEnumJsonRoundTrip: [ (CardType, CardType.POKEMON), (PokemonStage, PokemonStage.BASIC), + (PokemonVariant, PokemonVariant.EX), (EnergyType, EnergyType.FIRE), (TrainerType, TrainerType.SUPPORTER), (TurnPhase, TurnPhase.MAIN),