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:
Cal Corum 2026-01-24 22:35:31 -06:00
parent 3e82280efb
commit 32541af682
8 changed files with 2290 additions and 54 deletions

View File

@ -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):

View 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
}

View 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

View File

@ -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):

View File

@ -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:

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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),