mantimon-tcg/backend/tests/core/conftest.py
Cal Corum 725c8ccc5c Add GameState, PlayerState, Zone models and test fixtures
Core game state models:
- Zone: Card collection with deck operations (draw, shuffle, peek, etc.)
- PlayerState: All player zones, score, and per-turn action flags
- GameState: Complete game state with card registry, turn tracking, win conditions

Test fixtures (conftest.py):
- Sample card definitions: Pokemon (Pikachu, Raichu, Charizard, EX, V, VMAX)
- Trainer cards: Item (Potion), Supporter (Professor Oak), Stadium, Tool
- Energy cards: Basic and special energy
- Pre-configured game states: empty, mid-game, near-win scenarios
- Factory fixtures for CardInstance and SeededRandom

Tests: 55 new tests for game state models (259 total passing)

Note: GameState imported directly from game_state module to avoid
circular imports with config module.
2026-01-24 22:55:31 -06:00

611 lines
18 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.models.card import (
Ability,
Attack,
CardDefinition,
CardInstance,
WeaknessResistance,
)
from app.core.models.enums import (
CardType,
EnergyType,
PokemonStage,
PokemonVariant,
TrainerType,
TurnPhase,
)
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_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)
"""
_counter = [0]
def _create_instance(
definition_id: str,
instance_id: str | None = None,
damage: int = 0,
turn_played: int | None = None,
) -> CardInstance:
if instance_id is None:
_counter[0] += 1
instance_id = f"inst_{definition_id}_{_counter[0]}"
return CardInstance(
instance_id=instance_id,
definition_id=definition_id,
damage=damage,
turn_played=turn_played,
)
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()