Architectural refactor to eliminate circular imports and establish clean module boundaries: - Move enums from app/core/models/enums.py to app/core/enums.py (foundational module with zero dependencies) - Update all imports across 30 files to use new enum location - Set up clean export structure: - app.core.enums: canonical source for all enums - app.core: convenience exports for full public API - app.core.models: exports models only (not enums) - Add module exports to app/core/__init__.py and app/core/effects/__init__.py - Remove circular import workarounds from game_state.py This enables app.core.models to export GameState without circular import issues, since enums no longer depend on the models package. All 826 tests passing.
961 lines
29 KiB
Python
961 lines
29 KiB
Python
"""Pytest fixtures for the core game engine tests.
|
|
|
|
This module provides reusable fixtures for testing the game engine:
|
|
- Sample card definitions (Pokemon, Trainer, Energy)
|
|
- Pre-configured game states
|
|
- Seeded RNG instances for deterministic testing
|
|
- Helper functions for creating test data
|
|
|
|
Usage:
|
|
def test_something(sample_pokemon, seeded_rng):
|
|
# sample_pokemon is a CardDefinition
|
|
# seeded_rng is a SeededRandom instance
|
|
pass
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app.core.config import RulesConfig
|
|
from app.core.enums import (
|
|
CardType,
|
|
EnergyType,
|
|
PokemonStage,
|
|
PokemonVariant,
|
|
TrainerType,
|
|
TurnPhase,
|
|
)
|
|
from app.core.models.card import (
|
|
Ability,
|
|
Attack,
|
|
CardDefinition,
|
|
CardInstance,
|
|
WeaknessResistance,
|
|
)
|
|
from app.core.models.game_state import GameState, PlayerState
|
|
from app.core.rng import SeededRandom
|
|
|
|
# ============================================================================
|
|
# RNG Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_rng() -> SeededRandom:
|
|
"""Provide a SeededRandom instance with a fixed seed for deterministic tests.
|
|
|
|
The seed 42 is used consistently across tests for reproducibility.
|
|
"""
|
|
return SeededRandom(seed=42)
|
|
|
|
|
|
@pytest.fixture
|
|
def rng_factory():
|
|
"""Factory fixture to create SeededRandom instances with custom seeds.
|
|
|
|
Usage:
|
|
def test_something(rng_factory):
|
|
rng1 = rng_factory(seed=100)
|
|
rng2 = rng_factory(seed=200)
|
|
"""
|
|
|
|
def _create_rng(seed: int = 42) -> SeededRandom:
|
|
return SeededRandom(seed=seed)
|
|
|
|
return _create_rng
|
|
|
|
|
|
# ============================================================================
|
|
# Card Definition Fixtures - Pokemon
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def pikachu_def() -> CardDefinition:
|
|
"""Basic Lightning Pokemon - Pikachu.
|
|
|
|
A simple Basic Pokemon with one attack and standard stats.
|
|
Used as the canonical "basic Pokemon" in tests.
|
|
"""
|
|
return CardDefinition(
|
|
id="pikachu_base_001",
|
|
name="Pikachu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.NORMAL,
|
|
hp=60,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[
|
|
Attack(
|
|
name="Thunder Shock",
|
|
cost=[EnergyType.LIGHTNING],
|
|
damage=20,
|
|
effect_id="may_paralyze",
|
|
effect_params={"chance": 0.5},
|
|
effect_description="Flip a coin. If heads, the Defending Pokemon is now Paralyzed.",
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
|
|
retreat_cost=1,
|
|
rarity="common",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def raichu_def() -> CardDefinition:
|
|
"""Stage 1 Lightning Pokemon - Raichu.
|
|
|
|
Evolves from Pikachu. Used for evolution tests.
|
|
"""
|
|
return CardDefinition(
|
|
id="raichu_base_001",
|
|
name="Raichu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.STAGE_1,
|
|
variant=PokemonVariant.NORMAL,
|
|
evolves_from="Pikachu",
|
|
hp=90,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[
|
|
Attack(
|
|
name="Thunder",
|
|
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.COLORLESS],
|
|
damage=60,
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
|
|
retreat_cost=1,
|
|
rarity="rare",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def charizard_def() -> CardDefinition:
|
|
"""Stage 2 Fire Pokemon - Charizard.
|
|
|
|
Classic high-HP Stage 2 Pokemon. Evolves from Charmeleon.
|
|
"""
|
|
return CardDefinition(
|
|
id="charizard_base_001",
|
|
name="Charizard",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.STAGE_2,
|
|
variant=PokemonVariant.NORMAL,
|
|
evolves_from="Charmeleon",
|
|
hp=120,
|
|
pokemon_type=EnergyType.FIRE,
|
|
attacks=[
|
|
Attack(
|
|
name="Fire Spin",
|
|
cost=[EnergyType.FIRE, EnergyType.FIRE, EnergyType.FIRE, EnergyType.FIRE],
|
|
damage=100,
|
|
effect_id="discard_energy",
|
|
effect_params={"count": 2, "type": "fire"},
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.WATER, modifier=2),
|
|
resistance=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=-30),
|
|
retreat_cost=3,
|
|
rarity="rare_holo",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mewtwo_ex_def() -> CardDefinition:
|
|
"""Basic EX Pokemon - Mewtwo EX.
|
|
|
|
High-HP Pokemon worth 2 knockout points.
|
|
"""
|
|
return CardDefinition(
|
|
id="mewtwo_ex_001",
|
|
name="Mewtwo EX",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.EX,
|
|
hp=170,
|
|
pokemon_type=EnergyType.PSYCHIC,
|
|
attacks=[
|
|
Attack(
|
|
name="Psydrive",
|
|
cost=[EnergyType.PSYCHIC, EnergyType.COLORLESS],
|
|
damage=120,
|
|
effect_id="discard_energy",
|
|
effect_params={"count": 1, "type": "any"},
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.PSYCHIC, modifier=2),
|
|
retreat_cost=2,
|
|
rarity="ultra_rare",
|
|
set_id="ex_series",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pikachu_v_def() -> CardDefinition:
|
|
"""Basic V Pokemon - Pikachu V.
|
|
|
|
V Pokemon worth 2 knockout points.
|
|
"""
|
|
return CardDefinition(
|
|
id="pikachu_v_001",
|
|
name="Pikachu V",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.V,
|
|
hp=190,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[
|
|
Attack(
|
|
name="Volt Tackle",
|
|
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.COLORLESS],
|
|
damage=210,
|
|
effect_id="self_damage",
|
|
effect_params={"amount": 30},
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
|
|
retreat_cost=2,
|
|
rarity="ultra_rare",
|
|
set_id="v_series",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pikachu_vmax_def() -> CardDefinition:
|
|
"""VMAX Pokemon - Pikachu VMAX.
|
|
|
|
Evolves from Pikachu V, worth 3 knockout points.
|
|
"""
|
|
return CardDefinition(
|
|
id="pikachu_vmax_001",
|
|
name="Pikachu VMAX",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC, # Stage is still Basic
|
|
variant=PokemonVariant.VMAX, # Variant indicates V evolution
|
|
evolves_from="Pikachu V",
|
|
hp=310,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[
|
|
Attack(
|
|
name="G-Max Volt Crash",
|
|
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.LIGHTNING],
|
|
damage=270,
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
|
|
retreat_cost=3,
|
|
rarity="secret_rare",
|
|
set_id="vmax_series",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pokemon_with_ability_def() -> CardDefinition:
|
|
"""Pokemon with an Ability - Shaymin EX.
|
|
|
|
Used for ability testing.
|
|
"""
|
|
return CardDefinition(
|
|
id="shaymin_ex_001",
|
|
name="Shaymin EX",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.EX,
|
|
hp=110,
|
|
pokemon_type=EnergyType.COLORLESS,
|
|
abilities=[
|
|
Ability(
|
|
name="Set Up",
|
|
effect_id="draw_until_hand_size",
|
|
effect_params={"count": 6},
|
|
effect_description="When you play this Pokemon from your hand to your Bench, "
|
|
"you may draw cards until you have 6 cards in your hand.",
|
|
once_per_turn=True,
|
|
),
|
|
],
|
|
attacks=[
|
|
Attack(
|
|
name="Sky Return",
|
|
cost=[EnergyType.COLORLESS, EnergyType.COLORLESS],
|
|
damage=30,
|
|
effect_id="return_to_hand",
|
|
),
|
|
],
|
|
retreat_cost=1,
|
|
rarity="ultra_rare",
|
|
set_id="ex_series",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Card Definition Fixtures - Trainers
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def potion_def() -> CardDefinition:
|
|
"""Item card - Potion.
|
|
|
|
Basic healing item.
|
|
"""
|
|
return CardDefinition(
|
|
id="potion_base_001",
|
|
name="Potion",
|
|
card_type=CardType.TRAINER,
|
|
trainer_type=TrainerType.ITEM,
|
|
effect_id="heal",
|
|
effect_params={"amount": 30},
|
|
effect_description="Heal 30 damage from one of your Pokemon.",
|
|
rarity="common",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def professor_oak_def() -> CardDefinition:
|
|
"""Supporter card - Professor Oak.
|
|
|
|
Classic draw supporter.
|
|
"""
|
|
return CardDefinition(
|
|
id="professor_oak_001",
|
|
name="Professor Oak",
|
|
card_type=CardType.TRAINER,
|
|
trainer_type=TrainerType.SUPPORTER,
|
|
effect_id="discard_hand_draw",
|
|
effect_params={"draw_count": 7},
|
|
effect_description="Discard your hand and draw 7 cards.",
|
|
rarity="uncommon",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pokemon_center_def() -> CardDefinition:
|
|
"""Stadium card - Pokemon Center.
|
|
|
|
Example stadium that stays in play.
|
|
"""
|
|
return CardDefinition(
|
|
id="pokemon_center_001",
|
|
name="Pokemon Center",
|
|
card_type=CardType.TRAINER,
|
|
trainer_type=TrainerType.STADIUM,
|
|
effect_id="stadium_heal_between_turns",
|
|
effect_params={"amount": 20},
|
|
effect_description="Between turns, heal 20 damage from each player's Active Pokemon.",
|
|
rarity="uncommon",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def choice_band_def() -> CardDefinition:
|
|
"""Tool card - Choice Band.
|
|
|
|
Damage-boosting tool.
|
|
"""
|
|
return CardDefinition(
|
|
id="choice_band_001",
|
|
name="Choice Band",
|
|
card_type=CardType.TRAINER,
|
|
trainer_type=TrainerType.TOOL,
|
|
effect_id="damage_boost_vs_ex_gx",
|
|
effect_params={"amount": 30},
|
|
effect_description="The attacks of the Pokemon this is attached to do 30 more damage "
|
|
"to your opponent's Active Pokemon-EX or Pokemon-GX.",
|
|
rarity="uncommon",
|
|
set_id="modern",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Card Definition Fixtures - Energy
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def lightning_energy_def() -> CardDefinition:
|
|
"""Basic Lightning Energy."""
|
|
return CardDefinition(
|
|
id="lightning_energy_001",
|
|
name="Lightning Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.LIGHTNING,
|
|
energy_provides=[EnergyType.LIGHTNING],
|
|
rarity="common",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def fire_energy_def() -> CardDefinition:
|
|
"""Basic Fire Energy."""
|
|
return CardDefinition(
|
|
id="fire_energy_001",
|
|
name="Fire Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.FIRE,
|
|
energy_provides=[EnergyType.FIRE],
|
|
rarity="common",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def double_colorless_energy_def() -> CardDefinition:
|
|
"""Special Energy - Double Colorless Energy.
|
|
|
|
Provides 2 Colorless energy.
|
|
"""
|
|
return CardDefinition(
|
|
id="dce_001",
|
|
name="Double Colorless Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.COLORLESS,
|
|
energy_provides=[EnergyType.COLORLESS, EnergyType.COLORLESS],
|
|
rarity="uncommon",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Card Registry Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_card_registry(
|
|
pikachu_def,
|
|
raichu_def,
|
|
charizard_def,
|
|
potion_def,
|
|
professor_oak_def,
|
|
lightning_energy_def,
|
|
fire_energy_def,
|
|
) -> dict[str, CardDefinition]:
|
|
"""A basic card registry with common test cards.
|
|
|
|
Includes: Pikachu, Raichu, Charizard, Potion, Professor Oak, basic energy.
|
|
"""
|
|
cards = [
|
|
pikachu_def,
|
|
raichu_def,
|
|
charizard_def,
|
|
potion_def,
|
|
professor_oak_def,
|
|
lightning_energy_def,
|
|
fire_energy_def,
|
|
]
|
|
return {card.id: card for card in cards}
|
|
|
|
|
|
# ============================================================================
|
|
# Card Instance Factory
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def card_instance_factory():
|
|
"""Factory fixture to create CardInstance objects.
|
|
|
|
Usage:
|
|
def test_something(card_instance_factory):
|
|
card = card_instance_factory("pikachu_base_001")
|
|
card_with_damage = card_instance_factory("pikachu_base_001", damage=30)
|
|
card_evolved = card_instance_factory("raichu_base_001", turn_evolved=2)
|
|
"""
|
|
_counter = [0]
|
|
|
|
def _create_instance(
|
|
definition_id: str,
|
|
instance_id: str | None = None,
|
|
damage: int = 0,
|
|
turn_played: int | None = None,
|
|
turn_evolved: int | None = None,
|
|
) -> CardInstance:
|
|
if instance_id is None:
|
|
_counter[0] += 1
|
|
instance_id = f"inst_{definition_id}_{_counter[0]}"
|
|
|
|
card = CardInstance(
|
|
instance_id=instance_id,
|
|
definition_id=definition_id,
|
|
damage=damage,
|
|
turn_played=turn_played,
|
|
)
|
|
if turn_evolved is not None:
|
|
card.turn_evolved = turn_evolved
|
|
return card
|
|
|
|
return _create_instance
|
|
|
|
|
|
# ============================================================================
|
|
# Game State Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_game_state(basic_card_registry) -> GameState:
|
|
"""An empty game state ready for setup.
|
|
|
|
Has two players with empty zones, in SETUP phase.
|
|
"""
|
|
return GameState(
|
|
game_id="test_game_001",
|
|
rules=RulesConfig(),
|
|
card_registry=basic_card_registry,
|
|
players={
|
|
"player1": PlayerState(player_id="player1"),
|
|
"player2": PlayerState(player_id="player2"),
|
|
},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=0,
|
|
phase=TurnPhase.SETUP,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mid_game_state(basic_card_registry, card_instance_factory) -> GameState:
|
|
"""A game state in the middle of play.
|
|
|
|
- Turn 3, player1's turn, MAIN phase
|
|
- Player1: Pikachu active, Raichu on bench, 3 cards in hand, score 1
|
|
- Player2: Charizard active, 4 cards in hand, score 0
|
|
- Both players have cards in deck and discard
|
|
"""
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 setup
|
|
player1.active.add(card_instance_factory("pikachu_base_001", turn_played=1))
|
|
player1.bench.add(card_instance_factory("raichu_base_001", turn_played=2))
|
|
for _ in range(3):
|
|
player1.hand.add(card_instance_factory("lightning_energy_001"))
|
|
for _ in range(10):
|
|
player1.deck.add(card_instance_factory("pikachu_base_001"))
|
|
player1.discard.add(card_instance_factory("potion_base_001"))
|
|
player1.score = 1
|
|
|
|
# Player 2 setup
|
|
player2.active.add(card_instance_factory("charizard_base_001", turn_played=1, damage=40))
|
|
for _ in range(4):
|
|
player2.hand.add(card_instance_factory("fire_energy_001"))
|
|
for _ in range(8):
|
|
player2.deck.add(card_instance_factory("charizard_base_001"))
|
|
player2.discard.add(card_instance_factory("professor_oak_001"))
|
|
player2.score = 0
|
|
|
|
return GameState(
|
|
game_id="test_game_mid",
|
|
rules=RulesConfig(),
|
|
card_registry=basic_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=3,
|
|
phase=TurnPhase.MAIN,
|
|
first_turn_completed=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def game_near_win_state(basic_card_registry, card_instance_factory) -> GameState:
|
|
"""A game state where player1 is about to win (3/4 points).
|
|
|
|
Used for testing win condition detection.
|
|
"""
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 setup - one knockout away from winning
|
|
player1.active.add(card_instance_factory("pikachu_base_001"))
|
|
player1.score = 3 # 4 points needed to win
|
|
|
|
# Player 2 setup - low HP Pokemon active
|
|
damaged_pikachu = card_instance_factory("pikachu_base_001", damage=50) # 10 HP remaining
|
|
player2.active.add(damaged_pikachu)
|
|
|
|
return GameState(
|
|
game_id="test_game_near_win",
|
|
rules=RulesConfig(), # Default: 4 points to win
|
|
card_registry=basic_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=5,
|
|
phase=TurnPhase.ATTACK,
|
|
first_turn_completed=True,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Rules Config Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def default_rules() -> RulesConfig:
|
|
"""Default Mantimon TCG rules.
|
|
|
|
40-card deck, 4 points to win, Pokemon Pocket-style energy.
|
|
"""
|
|
return RulesConfig()
|
|
|
|
|
|
@pytest.fixture
|
|
def standard_tcg_rules() -> RulesConfig:
|
|
"""Standard Pokemon TCG rules.
|
|
|
|
60-card deck, 6 prizes, no energy deck.
|
|
"""
|
|
return RulesConfig.standard_pokemon_tcg()
|
|
|
|
|
|
# ============================================================================
|
|
# Additional Card Definition Fixtures for Evolution Testing
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def charmander_def() -> CardDefinition:
|
|
"""Basic Fire Pokemon - Charmander.
|
|
|
|
Used for evolution chain testing (Charmander -> Charmeleon -> Charizard).
|
|
"""
|
|
return CardDefinition(
|
|
id="charmander_base_001",
|
|
name="Charmander",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.NORMAL,
|
|
hp=50,
|
|
pokemon_type=EnergyType.FIRE,
|
|
attacks=[
|
|
Attack(
|
|
name="Scratch",
|
|
cost=[EnergyType.COLORLESS],
|
|
damage=10,
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.WATER, modifier=2),
|
|
retreat_cost=1,
|
|
rarity="common",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def charmeleon_def() -> CardDefinition:
|
|
"""Stage 1 Fire Pokemon - Charmeleon.
|
|
|
|
Evolves from Charmander. Used for evolution chain testing.
|
|
"""
|
|
return CardDefinition(
|
|
id="charmeleon_base_001",
|
|
name="Charmeleon",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.STAGE_1,
|
|
variant=PokemonVariant.NORMAL,
|
|
evolves_from="Charmander",
|
|
hp=80,
|
|
pokemon_type=EnergyType.FIRE,
|
|
attacks=[
|
|
Attack(
|
|
name="Slash",
|
|
cost=[EnergyType.FIRE, EnergyType.COLORLESS],
|
|
damage=30,
|
|
),
|
|
],
|
|
weakness=WeaknessResistance(energy_type=EnergyType.WATER, modifier=2),
|
|
retreat_cost=1,
|
|
rarity="uncommon",
|
|
set_id="base",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Extended Card Registry Fixture
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def extended_card_registry(
|
|
pikachu_def,
|
|
raichu_def,
|
|
charmander_def,
|
|
charmeleon_def,
|
|
charizard_def,
|
|
mewtwo_ex_def,
|
|
pikachu_v_def,
|
|
pikachu_vmax_def,
|
|
pokemon_with_ability_def,
|
|
potion_def,
|
|
professor_oak_def,
|
|
pokemon_center_def,
|
|
choice_band_def,
|
|
lightning_energy_def,
|
|
fire_energy_def,
|
|
double_colorless_energy_def,
|
|
) -> dict[str, CardDefinition]:
|
|
"""Extended card registry with all test cards.
|
|
|
|
Includes full evolution chains and all card types for comprehensive testing.
|
|
"""
|
|
cards = [
|
|
pikachu_def,
|
|
raichu_def,
|
|
charmander_def,
|
|
charmeleon_def,
|
|
charizard_def,
|
|
mewtwo_ex_def,
|
|
pikachu_v_def,
|
|
pikachu_vmax_def,
|
|
pokemon_with_ability_def,
|
|
potion_def,
|
|
professor_oak_def,
|
|
pokemon_center_def,
|
|
choice_band_def,
|
|
lightning_energy_def,
|
|
fire_energy_def,
|
|
double_colorless_energy_def,
|
|
]
|
|
return {card.id: card for card in cards}
|
|
|
|
|
|
# ============================================================================
|
|
# Game State Fixtures for Rules Validation
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def game_in_main_phase(extended_card_registry, card_instance_factory) -> GameState:
|
|
"""Game state in MAIN phase for testing main phase actions.
|
|
|
|
- Turn 2, player1's turn, MAIN phase
|
|
- Player1: Pikachu active (with 1 lightning energy), Charmander on bench, cards in hand
|
|
- Player2: Raichu active
|
|
"""
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 setup - active with energy, bench pokemon, cards in hand
|
|
pikachu = card_instance_factory("pikachu_base_001", turn_played=1)
|
|
# Attach energy as a CardInstance - energy is stored directly on the Pokemon
|
|
energy = card_instance_factory("lightning_energy_001", instance_id="energy_lightning_1")
|
|
pikachu.attach_energy(energy)
|
|
player1.active.add(pikachu)
|
|
player1.bench.add(card_instance_factory("charmander_base_001", turn_played=1))
|
|
|
|
# Cards in hand: evolution card, energy, trainer
|
|
player1.hand.add(card_instance_factory("raichu_base_001", instance_id="hand_raichu"))
|
|
player1.hand.add(card_instance_factory("charmeleon_base_001", instance_id="hand_charmeleon"))
|
|
player1.hand.add(card_instance_factory("lightning_energy_001", instance_id="hand_energy"))
|
|
player1.hand.add(card_instance_factory("potion_base_001", instance_id="hand_potion"))
|
|
player1.hand.add(card_instance_factory("professor_oak_001", instance_id="hand_supporter"))
|
|
player1.hand.add(card_instance_factory("pokemon_center_001", instance_id="hand_stadium"))
|
|
player1.hand.add(card_instance_factory("pikachu_base_001", instance_id="hand_basic"))
|
|
|
|
# Energy in energy zone (for Pokemon Pocket style)
|
|
player1.energy_zone.add(
|
|
card_instance_factory("lightning_energy_001", instance_id="zone_energy")
|
|
)
|
|
|
|
# Some deck cards
|
|
for i in range(10):
|
|
player1.deck.add(card_instance_factory("pikachu_base_001", instance_id=f"deck_{i}"))
|
|
|
|
# Player 2 setup
|
|
player2.active.add(card_instance_factory("raichu_base_001", turn_played=1))
|
|
for i in range(10):
|
|
player2.deck.add(card_instance_factory("pikachu_base_001", instance_id=f"p2_deck_{i}"))
|
|
|
|
return GameState(
|
|
game_id="test_main_phase",
|
|
rules=RulesConfig(),
|
|
card_registry=extended_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=2,
|
|
phase=TurnPhase.MAIN,
|
|
first_turn_completed=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def game_in_attack_phase(extended_card_registry, card_instance_factory) -> GameState:
|
|
"""Game state in ATTACK phase for testing attack validation.
|
|
|
|
- Turn 2, player1's turn, ATTACK phase
|
|
- Player1: Pikachu active with enough energy for Thunder Shock
|
|
- Player2: Raichu active
|
|
"""
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 - Pikachu with 1 lightning energy (enough for Thunder Shock)
|
|
pikachu = card_instance_factory("pikachu_base_001", turn_played=1)
|
|
# Attach energy as a CardInstance - energy is stored directly on the Pokemon
|
|
energy = card_instance_factory("lightning_energy_001", instance_id="energy_lightning_1")
|
|
pikachu.attach_energy(energy)
|
|
player1.active.add(pikachu)
|
|
player1.bench.add(card_instance_factory("charmander_base_001", turn_played=1))
|
|
|
|
for i in range(10):
|
|
player1.deck.add(card_instance_factory("pikachu_base_001", instance_id=f"deck_{i}"))
|
|
|
|
# Player 2
|
|
player2.active.add(card_instance_factory("raichu_base_001", turn_played=1))
|
|
|
|
return GameState(
|
|
game_id="test_attack_phase",
|
|
rules=RulesConfig(),
|
|
card_registry=extended_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=2,
|
|
phase=TurnPhase.ATTACK,
|
|
first_turn_completed=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def game_in_setup_phase(extended_card_registry, card_instance_factory) -> GameState:
|
|
"""Game state in SETUP phase for testing setup actions.
|
|
|
|
- Turn 0, SETUP phase
|
|
- Both players have empty zones but basic pokemon in hand
|
|
"""
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 - basic pokemon in hand for setup
|
|
player1.hand.add(card_instance_factory("pikachu_base_001", instance_id="p1_hand_basic1"))
|
|
player1.hand.add(card_instance_factory("charmander_base_001", instance_id="p1_hand_basic2"))
|
|
|
|
# Player 2 - basic pokemon in hand
|
|
player2.hand.add(card_instance_factory("pikachu_base_001", instance_id="p2_hand_basic1"))
|
|
|
|
return GameState(
|
|
game_id="test_setup",
|
|
rules=RulesConfig(),
|
|
card_registry=extended_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=0,
|
|
phase=TurnPhase.SETUP,
|
|
first_turn_completed=False,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def game_first_turn(extended_card_registry, card_instance_factory) -> GameState:
|
|
"""Game state on the first turn for testing first-turn restrictions.
|
|
|
|
- Turn 1, player1's turn, MAIN phase
|
|
- First turn restrictions apply
|
|
"""
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 - just placed basic, has cards in hand
|
|
player1.active.add(card_instance_factory("pikachu_base_001", turn_played=1))
|
|
player1.hand.add(card_instance_factory("lightning_energy_001", instance_id="hand_energy"))
|
|
player1.hand.add(card_instance_factory("raichu_base_001", instance_id="hand_raichu"))
|
|
player1.hand.add(card_instance_factory("professor_oak_001", instance_id="hand_supporter"))
|
|
|
|
for i in range(10):
|
|
player1.deck.add(card_instance_factory("pikachu_base_001", instance_id=f"deck_{i}"))
|
|
|
|
# Player 2
|
|
player2.active.add(card_instance_factory("raichu_base_001", turn_played=1))
|
|
|
|
return GameState(
|
|
game_id="test_first_turn",
|
|
rules=RulesConfig(),
|
|
card_registry=extended_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=1,
|
|
phase=TurnPhase.MAIN,
|
|
first_turn_completed=False, # Still first turn
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def game_with_forced_action(extended_card_registry, card_instance_factory) -> GameState:
|
|
"""Game state with a forced action pending.
|
|
|
|
- Player2's active was knocked out, must select new active
|
|
- Player1 just attacked and knocked out player2's active
|
|
"""
|
|
from app.core.models.game_state import ForcedAction
|
|
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
# Player 1 - active pokemon
|
|
player1.active.add(card_instance_factory("pikachu_base_001", turn_played=1))
|
|
|
|
# Player 2 - no active (knocked out), but has bench
|
|
player2.bench.add(card_instance_factory("charmander_base_001", instance_id="p2_bench1"))
|
|
player2.bench.add(card_instance_factory("pikachu_base_001", instance_id="p2_bench2"))
|
|
|
|
game = GameState(
|
|
game_id="test_forced_action",
|
|
rules=RulesConfig(),
|
|
card_registry=extended_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=3,
|
|
phase=TurnPhase.ATTACK,
|
|
first_turn_completed=True,
|
|
forced_actions=[
|
|
ForcedAction(
|
|
player_id="player2",
|
|
action_type="select_active",
|
|
reason="Active Pokemon was knocked out",
|
|
)
|
|
],
|
|
)
|
|
|
|
return game
|
|
|
|
|
|
@pytest.fixture
|
|
def game_over_state(extended_card_registry, card_instance_factory) -> GameState:
|
|
"""Game state where the game is over.
|
|
|
|
- Player1 has won
|
|
"""
|
|
from app.core.enums import GameEndReason
|
|
|
|
player1 = PlayerState(player_id="player1")
|
|
player2 = PlayerState(player_id="player2")
|
|
|
|
player1.active.add(card_instance_factory("pikachu_base_001"))
|
|
player1.score = 4 # Won!
|
|
|
|
return GameState(
|
|
game_id="test_game_over",
|
|
rules=RulesConfig(),
|
|
card_registry=extended_card_registry,
|
|
players={"player1": player1, "player2": player2},
|
|
turn_order=["player1", "player2"],
|
|
current_player_id="player1",
|
|
turn_number=10,
|
|
phase=TurnPhase.END,
|
|
first_turn_completed=True,
|
|
winner_id="player1",
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
)
|