Add card/action models with stage/variant separation
- Add CardDefinition and CardInstance models for card templates and in-game state - Add Attack, Ability, and WeaknessResistance models for Pokemon card components - Add 11 action types as discriminated union (PlayPokemon, Evolve, Attack, etc.) - Split PokemonStage (BASIC, STAGE_1, STAGE_2) from PokemonVariant (NORMAL, EX, GX, V, VMAX, VSTAR) - Stage determines evolution mechanics, variant determines knockout points - Update PrizeConfig to use variant for knockout point calculation - VSTAR and VMAX both worth 3 points; EX, GX, V worth 2 points; NORMAL worth 1 point Tests: 204 passing, all linting clean
This commit is contained in:
parent
3e82280efb
commit
32541af682
@ -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):
|
||||
|
||||
276
backend/app/core/models/actions.py
Normal file
276
backend/app/core/models/actions.py
Normal file
@ -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
|
||||
}
|
||||
363
backend/app/core/models/card.py
Normal file
363
backend/app/core/models/card.py
Normal file
@ -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
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
494
backend/tests/core/test_models/test_actions.py
Normal file
494
backend/tests/core/test_models/test_actions.py
Normal file
@ -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
|
||||
1014
backend/tests/core/test_models/test_card.py
Normal file
1014
backend/tests/core/test_models/test_card.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user