mantimon-tcg/backend/tests/core/test_evolution_stack.py
Cal Corum 7fae1c61e8 Add CardDefinition validation for required fields (Issue #2)
- Add model_validator to enforce card-type-specific required fields
- Pokemon: require hp (positive), stage, pokemon_type
- Pokemon Stage 1/2 and VMAX/VSTAR: require evolves_from
- Trainer: require trainer_type
- Energy: require energy_type (auto-fills energy_provides)
- Update all test fixtures to include required fields
- Mark Issue #2 as FIXED in SYSTEM_REVIEW.md

765 tests passing
2026-01-26 10:28:37 -06:00

1171 lines
40 KiB
Python

"""Tests for the evolution stack and attached cards mechanics.
This module tests the new CardInstance fields and their integration:
- cards_underneath: Evolution stack tracking (Basic -> Stage 1 -> Stage 2)
- attached_energy: Energy cards as CardInstance objects stored on Pokemon
- attached_tools: Tool cards as CardInstance objects stored on Pokemon
Also tests the devolve effect handler and knockout processing with attachments.
Test categories:
- Evolution stack building (evolve mechanics)
- Energy/tool attachment and transfer during evolution
- Damage carryover on evolution
- Devolve effect handler
- Knockout processing with attached cards
- find_card_instance for cards in attachments/stack
"""
import pytest
# Import handlers to register them
import app.core.effects.handlers # noqa: F401
from app.core.config import RulesConfig
from app.core.effects.base import EffectContext
from app.core.effects.registry import resolve_effect
from app.core.engine import GameEngine
from app.core.models.actions import EvolvePokemonAction
from app.core.models.card import Attack, CardDefinition, CardInstance
from app.core.models.enums import (
CardType,
EnergyType,
PokemonStage,
PokemonVariant,
StatusCondition,
TrainerType,
TurnPhase,
)
from app.core.models.game_state import GameState, PlayerState
from app.core.rng import SeededRandom
from app.core.turn_manager import TurnManager
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def basic_pokemon_def() -> CardDefinition:
"""Basic Pokemon (Charmander) - can evolve to Stage 1."""
return CardDefinition(
id="charmander-001",
name="Charmander",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.NORMAL,
hp=50,
pokemon_type=EnergyType.FIRE,
retreat_cost=1,
)
@pytest.fixture
def stage1_pokemon_def() -> CardDefinition:
"""Stage 1 Pokemon (Charmeleon) - evolves from Basic, can evolve to Stage 2."""
return CardDefinition(
id="charmeleon-001",
name="Charmeleon",
card_type=CardType.POKEMON,
stage=PokemonStage.STAGE_1,
variant=PokemonVariant.NORMAL,
evolves_from="Charmander", # Uses name, not ID
hp=80,
pokemon_type=EnergyType.FIRE,
retreat_cost=2,
attacks=[
Attack(
name="Slash",
damage=30,
cost=[EnergyType.FIRE],
),
],
)
@pytest.fixture
def stage2_pokemon_def() -> CardDefinition:
"""Stage 2 Pokemon (Charizard) - evolves from Stage 1."""
return CardDefinition(
id="charizard-001",
name="Charizard",
card_type=CardType.POKEMON,
stage=PokemonStage.STAGE_2,
variant=PokemonVariant.NORMAL,
evolves_from="Charmeleon", # Uses name, not ID
hp=150,
pokemon_type=EnergyType.FIRE,
retreat_cost=3,
attacks=[
Attack(
name="Fire Spin",
damage=120,
cost=[EnergyType.FIRE, EnergyType.FIRE, EnergyType.FIRE],
),
],
)
@pytest.fixture
def fire_energy_def() -> CardDefinition:
"""Fire energy card definition."""
return CardDefinition(
id="fire-energy-001",
name="Fire Energy",
card_type=CardType.ENERGY,
energy_type=EnergyType.FIRE,
energy_provides=[EnergyType.FIRE],
)
@pytest.fixture
def tool_def() -> CardDefinition:
"""Pokemon Tool card definition."""
return CardDefinition(
id="choice-band-001",
name="Choice Band",
card_type=CardType.TRAINER,
trainer_type=TrainerType.TOOL,
effect_id="modify_damage",
effect_params={"amount": 30, "condition": "vs_gx_ex"},
effect_description="+30 damage against GX/EX",
)
@pytest.fixture
def game_state(
basic_pokemon_def: CardDefinition,
stage1_pokemon_def: CardDefinition,
stage2_pokemon_def: CardDefinition,
fire_energy_def: CardDefinition,
tool_def: CardDefinition,
) -> GameState:
"""Create a game state set up for evolution testing.
Player 1 has:
- Active: Charmander (Basic, played last turn)
- Hand: Charmeleon, Charizard, 2x Fire Energy
- Deck: 5 cards
Player 2 has:
- Active: Charmander
"""
game = GameState(
game_id="test-evolution",
rules=RulesConfig(),
card_registry={
basic_pokemon_def.id: basic_pokemon_def,
stage1_pokemon_def.id: stage1_pokemon_def,
stage2_pokemon_def.id: stage2_pokemon_def,
fire_energy_def.id: fire_energy_def,
tool_def.id: tool_def,
},
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=2, # Turn 2 so evolution is allowed
phase=TurnPhase.MAIN,
first_turn_completed=True,
)
# Player 1 setup
p1 = game.players["player1"]
# Active Charmander (played on turn 1)
charmander = CardInstance(
instance_id="p1-charmander",
definition_id=basic_pokemon_def.id,
turn_played=1,
)
p1.active.add(charmander)
# Evolution cards in hand
charmeleon = CardInstance(instance_id="p1-charmeleon", definition_id=stage1_pokemon_def.id)
charizard = CardInstance(instance_id="p1-charizard", definition_id=stage2_pokemon_def.id)
p1.hand.add(charmeleon)
p1.hand.add(charizard)
# Energy cards in hand
for i in range(2):
energy = CardInstance(instance_id=f"p1-energy-{i}", definition_id=fire_energy_def.id)
p1.hand.add(energy)
# Tool in hand
tool = CardInstance(instance_id="p1-tool", definition_id=tool_def.id)
p1.hand.add(tool)
# Deck
for i in range(5):
card = CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
p1.deck.add(card)
# Player 2 setup - simple active
p2 = game.players["player2"]
p2_charmander = CardInstance(
instance_id="p2-charmander",
definition_id=basic_pokemon_def.id,
turn_played=1,
)
p2.active.add(p2_charmander)
for i in range(5):
card = CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
p2.deck.add(card)
return game
@pytest.fixture
def seeded_rng() -> SeededRandom:
"""Create a seeded RNG for deterministic tests."""
return SeededRandom(seed=42)
@pytest.fixture
def engine(seeded_rng: SeededRandom) -> GameEngine:
"""Create a game engine with seeded RNG."""
return GameEngine(rng=seeded_rng)
# =============================================================================
# Evolution Stack Tests
# =============================================================================
class TestEvolutionStack:
"""Tests for the evolution stack (cards_underneath) mechanics."""
@pytest.mark.asyncio
async def test_basic_to_stage1_creates_stack(
self, engine: GameEngine, game_state: GameState
) -> None:
"""
Test that evolving Basic to Stage 1 creates correct evolution stack.
When Charmander evolves to Charmeleon, the Charmander CardInstance
should be stored in Charmeleon's cards_underneath list.
"""
action = EvolvePokemonAction(
evolution_card_id="p1-charmeleon",
target_pokemon_id="p1-charmander",
)
result = await engine.execute_action(game_state, "player1", action)
assert result.success, f"Evolution failed: {result.message}"
# Get the evolved Pokemon (now Charmeleon)
active = game_state.players["player1"].get_active_pokemon()
assert active is not None
assert active.definition_id == "charmeleon-001"
assert active.instance_id == "p1-charmeleon"
# Check evolution stack contains the Basic
assert len(active.cards_underneath) == 1
basic_in_stack = active.cards_underneath[0]
assert basic_in_stack.instance_id == "p1-charmander"
assert basic_in_stack.definition_id == "charmander-001"
@pytest.mark.asyncio
async def test_stage1_to_stage2_preserves_full_stack(
self, engine: GameEngine, game_state: GameState
) -> None:
"""
Test that evolving Stage 1 to Stage 2 preserves full evolution stack.
After Charmander -> Charmeleon -> Charizard, the Charizard should have
both Charmander and Charmeleon in its cards_underneath list in order.
"""
# First evolution: Basic -> Stage 1
action1 = EvolvePokemonAction(
evolution_card_id="p1-charmeleon",
target_pokemon_id="p1-charmander",
)
result1 = await engine.execute_action(game_state, "player1", action1)
assert result1.success
# Advance turn to allow second evolution (can't evolve same turn)
game_state.turn_number = 3
charmeleon = game_state.players["player1"].get_active_pokemon()
charmeleon.turn_played = 2 # Played last turn
# Second evolution: Stage 1 -> Stage 2
action2 = EvolvePokemonAction(
evolution_card_id="p1-charizard",
target_pokemon_id="p1-charmeleon",
)
result2 = await engine.execute_action(game_state, "player1", action2)
assert result2.success, f"Second evolution failed: {result2.message}"
# Get the final evolved Pokemon (Charizard)
active = game_state.players["player1"].get_active_pokemon()
assert active is not None
assert active.definition_id == "charizard-001"
# Check evolution stack has both previous stages
assert len(active.cards_underneath) == 2
# Index 0 should be the oldest (Basic)
assert active.cards_underneath[0].instance_id == "p1-charmander"
assert active.cards_underneath[0].definition_id == "charmander-001"
# Index 1 should be the more recent (Stage 1)
assert active.cards_underneath[1].instance_id == "p1-charmeleon"
assert active.cards_underneath[1].definition_id == "charmeleon-001"
@pytest.mark.asyncio
async def test_energy_transfers_on_evolution(
self, engine: GameEngine, game_state: GameState, fire_energy_def: CardDefinition
) -> None:
"""
Test that attached energy transfers to the evolved Pokemon.
Energy attached to Charmander should transfer to Charmeleon when
Charmander evolves. The energy CardInstance objects should be preserved.
"""
p1 = game_state.players["player1"]
charmander = p1.get_active_pokemon()
# Attach energy to Charmander before evolution
energy1 = CardInstance(instance_id="attached-energy-1", definition_id=fire_energy_def.id)
energy2 = CardInstance(instance_id="attached-energy-2", definition_id=fire_energy_def.id)
charmander.attach_energy(energy1)
charmander.attach_energy(energy2)
assert len(charmander.attached_energy) == 2
# Evolve
action = EvolvePokemonAction(
evolution_card_id="p1-charmeleon",
target_pokemon_id="p1-charmander",
)
result = await engine.execute_action(game_state, "player1", action)
assert result.success
# Get evolved Pokemon
charmeleon = p1.get_active_pokemon()
# Energy should have transferred
assert len(charmeleon.attached_energy) == 2
energy_ids = [e.instance_id for e in charmeleon.attached_energy]
assert "attached-energy-1" in energy_ids
assert "attached-energy-2" in energy_ids
@pytest.mark.asyncio
async def test_tools_transfer_on_evolution(
self, engine: GameEngine, game_state: GameState, tool_def: CardDefinition
) -> None:
"""
Test that attached tools transfer to the evolved Pokemon.
Tools attached to the Basic should transfer to the evolved Pokemon.
"""
p1 = game_state.players["player1"]
charmander = p1.get_active_pokemon()
# Attach tool to Charmander
tool = CardInstance(instance_id="attached-tool", definition_id=tool_def.id)
charmander.attach_tool(tool)
assert len(charmander.attached_tools) == 1
# Evolve
action = EvolvePokemonAction(
evolution_card_id="p1-charmeleon",
target_pokemon_id="p1-charmander",
)
result = await engine.execute_action(game_state, "player1", action)
assert result.success
# Get evolved Pokemon
charmeleon = p1.get_active_pokemon()
# Tool should have transferred
assert len(charmeleon.attached_tools) == 1
assert charmeleon.attached_tools[0].instance_id == "attached-tool"
@pytest.mark.asyncio
async def test_damage_carries_over_on_evolution(
self, engine: GameEngine, game_state: GameState
) -> None:
"""
Test that damage carries over to the evolved Pokemon.
If Charmander has 30 damage and evolves to Charmeleon (80 HP),
Charmeleon should still have 30 damage.
"""
p1 = game_state.players["player1"]
charmander = p1.get_active_pokemon()
# Deal damage to Charmander
charmander.damage = 30
# Evolve
action = EvolvePokemonAction(
evolution_card_id="p1-charmeleon",
target_pokemon_id="p1-charmander",
)
result = await engine.execute_action(game_state, "player1", action)
assert result.success
# Get evolved Pokemon
charmeleon = p1.get_active_pokemon()
# Damage should have carried over
assert charmeleon.damage == 30
@pytest.mark.asyncio
async def test_status_conditions_clear_on_evolution(
self, engine: GameEngine, game_state: GameState
) -> None:
"""
Test that status conditions are cleared when a Pokemon evolves.
This is standard Pokemon TCG behavior - evolution removes status.
"""
p1 = game_state.players["player1"]
charmander = p1.get_active_pokemon()
# Apply status conditions
charmander.add_status(StatusCondition.POISONED)
charmander.add_status(StatusCondition.CONFUSED)
assert len(charmander.status_conditions) == 2
# Evolve
action = EvolvePokemonAction(
evolution_card_id="p1-charmeleon",
target_pokemon_id="p1-charmander",
)
result = await engine.execute_action(game_state, "player1", action)
assert result.success
# Get evolved Pokemon
charmeleon = p1.get_active_pokemon()
# Status should be cleared
assert len(charmeleon.status_conditions) == 0
# =============================================================================
# Devolve Effect Tests
# =============================================================================
class TestDevolveEffect:
"""Tests for the devolve effect handler."""
@pytest.fixture(autouse=True)
def ensure_handlers_registered(self) -> None:
"""Ensure effect handlers are registered before each test.
This is needed because test_registry.py clears the registry for its tests,
and pytest may run our tests after that cleanup without re-importing.
"""
from app.core.effects.registry import list_effects
# Only re-import if registry was cleared (devolve not present)
if "devolve" not in list_effects():
import importlib
import app.core.effects.handlers
importlib.reload(app.core.effects.handlers)
@pytest.fixture
def evolved_game_state(
self,
basic_pokemon_def: CardDefinition,
stage1_pokemon_def: CardDefinition,
stage2_pokemon_def: CardDefinition,
fire_energy_def: CardDefinition,
) -> GameState:
"""Create a game state with an already-evolved Pokemon for devolve testing.
Player 1 has Charizard active with full evolution stack and attachments.
"""
game = GameState(
game_id="test-devolve",
rules=RulesConfig(),
card_registry={
basic_pokemon_def.id: basic_pokemon_def,
stage1_pokemon_def.id: stage1_pokemon_def,
stage2_pokemon_def.id: stage2_pokemon_def,
fire_energy_def.id: fire_energy_def,
},
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player2", # Opponent's turn (for devolve effect)
turn_number=5,
phase=TurnPhase.MAIN,
)
p1 = game.players["player1"]
# Build the evolution stack manually for testing
charmander = CardInstance(
instance_id="p1-charmander",
definition_id=basic_pokemon_def.id,
turn_played=1,
)
charmeleon = CardInstance(
instance_id="p1-charmeleon",
definition_id=stage1_pokemon_def.id,
turn_played=2,
)
charizard = CardInstance(
instance_id="p1-charizard",
definition_id=stage2_pokemon_def.id,
turn_played=3,
# Evolution stack: [Charmander, Charmeleon]
cards_underneath=[charmander, charmeleon],
)
# Attach energy to Charizard
for i in range(3):
energy = CardInstance(
instance_id=f"charizard-energy-{i}", definition_id=fire_energy_def.id
)
charizard.attach_energy(energy)
# Add some damage
charizard.damage = 40
p1.active.add(charizard)
# Add cards to deck
for i in range(5):
card = CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
p1.deck.add(card)
# Player 2 setup
p2 = game.players["player2"]
p2_pokemon = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(p2_pokemon)
for i in range(5):
card = CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
p2.deck.add(card)
return game
def test_devolve_stage2_to_stage1(
self, evolved_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test devolving a Stage 2 back to Stage 1 (single stage).
Charizard should become Charmeleon, and Charizard goes to hand.
Energy, damage, and remaining stack should transfer.
"""
ctx = EffectContext(
game=evolved_game_state,
source_player_id="player2",
source_card_id="p2-active",
params={"stages": 1, "destination": "hand"},
target_card_id="p1-charizard",
rng=seeded_rng,
)
result = resolve_effect("devolve", ctx)
assert result.success
assert "Devolved 1 stage" in result.message
# Check the active is now Charmeleon
active = evolved_game_state.players["player1"].get_active_pokemon()
assert active is not None
assert active.definition_id == "charmeleon-001"
assert active.instance_id == "p1-charmeleon"
# Check Charizard went to hand
p1_hand = evolved_game_state.players["player1"].hand
assert "p1-charizard" in p1_hand
# Check energy transferred to Charmeleon
assert len(active.attached_energy) == 3
# Check damage transferred
assert active.damage == 40
# Check remaining evolution stack (just Charmander now)
assert len(active.cards_underneath) == 1
assert active.cards_underneath[0].instance_id == "p1-charmander"
def test_devolve_stage2_to_basic(
self, evolved_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test devolving a Stage 2 back to Basic (two stages).
Charizard should become Charmander, and both Charizard and
Charmeleon go to hand.
"""
ctx = EffectContext(
game=evolved_game_state,
source_player_id="player2",
source_card_id="p2-active",
params={"stages": 2, "destination": "hand"},
target_card_id="p1-charizard",
rng=seeded_rng,
)
result = resolve_effect("devolve", ctx)
assert result.success
assert "Devolved 2 stage" in result.message
# Check the active is now Charmander (Basic)
active = evolved_game_state.players["player1"].get_active_pokemon()
assert active is not None
assert active.definition_id == "charmander-001"
assert active.instance_id == "p1-charmander"
# Check both Charizard and Charmeleon went to hand
p1_hand = evolved_game_state.players["player1"].hand
assert "p1-charizard" in p1_hand
assert "p1-charmeleon" in p1_hand
# Check energy still attached
assert len(active.attached_energy) == 3
# Check evolution stack is now empty
assert len(active.cards_underneath) == 0
def test_devolve_to_discard(
self, evolved_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test devolving with destination set to discard pile.
The removed evolution cards should go to the discard pile.
"""
ctx = EffectContext(
game=evolved_game_state,
source_player_id="player2",
source_card_id="p2-active",
params={"stages": 1, "destination": "discard"},
target_card_id="p1-charizard",
rng=seeded_rng,
)
result = resolve_effect("devolve", ctx)
assert result.success
# Check Charizard went to discard (not hand)
p1 = evolved_game_state.players["player1"]
assert "p1-charizard" not in p1.hand
assert "p1-charizard" in p1.discard
def test_devolve_triggers_knockout_if_damage_exceeds_hp(
self, evolved_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that devolving can cause a knockout if damage exceeds new HP.
If Charizard (150 HP) has 60 damage and devolves to Charmander (50 HP),
the damage exceeds HP and should flag as knockout.
"""
# Set high damage that exceeds Charmander's HP
p1 = evolved_game_state.players["player1"]
charizard = p1.get_active_pokemon()
charizard.damage = 60 # Exceeds Charmander's 50 HP
ctx = EffectContext(
game=evolved_game_state,
source_player_id="player2",
source_card_id="p2-active",
params={"stages": 2, "destination": "hand"}, # Devolve to Basic
target_card_id="p1-charizard",
rng=seeded_rng,
)
result = resolve_effect("devolve", ctx)
assert result.success
assert "knocked out" in result.message.lower()
assert result.details.get("knockout") is True
def test_cannot_devolve_basic_pokemon(
self, game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that attempting to devolve a Basic Pokemon fails.
A Basic Pokemon has no cards_underneath and cannot be devolved.
"""
ctx = EffectContext(
game=game_state,
source_player_id="player2",
source_card_id="p2-charmander",
params={"stages": 1},
target_card_id="p1-charmander", # This is a Basic
rng=seeded_rng,
)
result = resolve_effect("devolve", ctx)
assert not result.success
assert "not an evolved Pokemon" in result.message
def test_devolve_energy_remains_attached(
self, evolved_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that energy remains attached after devolve.
This is the designed behavior - energy stays with the Pokemon
through devolution.
"""
# Verify initial energy count
p1 = evolved_game_state.players["player1"]
charizard = p1.get_active_pokemon()
initial_energy_ids = [e.instance_id for e in charizard.attached_energy]
assert len(initial_energy_ids) == 3
ctx = EffectContext(
game=evolved_game_state,
source_player_id="player2",
source_card_id="p2-active",
params={"stages": 2}, # Devolve all the way to Basic
target_card_id="p1-charizard",
rng=seeded_rng,
)
result = resolve_effect("devolve", ctx)
assert result.success
# Get the devolved Pokemon (now Charmander)
charmander = p1.get_active_pokemon()
# All energy should still be attached
final_energy_ids = [e.instance_id for e in charmander.attached_energy]
assert len(final_energy_ids) == 3
assert set(final_energy_ids) == set(initial_energy_ids)
# =============================================================================
# Knockout with Attachments Tests
# =============================================================================
class TestKnockoutWithAttachments:
"""Tests for knockout processing with attached cards."""
@pytest.fixture
def knockout_game_state(
self,
basic_pokemon_def: CardDefinition,
stage1_pokemon_def: CardDefinition,
fire_energy_def: CardDefinition,
tool_def: CardDefinition,
) -> GameState:
"""Create a game state for knockout testing with attached cards."""
game = GameState(
game_id="test-knockout",
rules=RulesConfig(),
card_registry={
basic_pokemon_def.id: basic_pokemon_def,
stage1_pokemon_def.id: stage1_pokemon_def,
fire_energy_def.id: fire_energy_def,
tool_def.id: tool_def,
},
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player2",
turn_number=5,
phase=TurnPhase.END,
)
p1 = game.players["player1"]
# Build Charmeleon with Charmander underneath
charmander = CardInstance(
instance_id="ko-charmander",
definition_id=basic_pokemon_def.id,
)
charmeleon = CardInstance(
instance_id="ko-charmeleon",
definition_id=stage1_pokemon_def.id,
cards_underneath=[charmander],
)
# Attach energy and tool
energy1 = CardInstance(instance_id="ko-energy-1", definition_id=fire_energy_def.id)
energy2 = CardInstance(instance_id="ko-energy-2", definition_id=fire_energy_def.id)
tool = CardInstance(instance_id="ko-tool", definition_id=tool_def.id)
charmeleon.attach_energy(energy1)
charmeleon.attach_energy(energy2)
charmeleon.attach_tool(tool)
# Set damage to cause knockout (HP is 80)
charmeleon.damage = 80
p1.active.add(charmeleon)
# Add bench Pokemon for after KO
bench_pokemon = CardInstance(
instance_id="p1-bench",
definition_id=basic_pokemon_def.id,
)
p1.bench.add(bench_pokemon)
# Deck
for i in range(5):
card = CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
p1.deck.add(card)
# Player 2
p2 = game.players["player2"]
p2_active = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(p2_active)
for i in range(5):
card = CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
p2.deck.add(card)
return game
def test_attached_energy_goes_to_discard_on_knockout(
self, knockout_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that attached energy cards go to the owner's discard pile on knockout.
When a Pokemon is knocked out, all attached energy CardInstance objects
should be moved to the player's discard pile.
"""
turn_manager = TurnManager()
p1 = knockout_game_state.players["player1"]
# Verify energy is attached
charmeleon = p1.get_active_pokemon()
assert len(charmeleon.attached_energy) == 2
# Initial discard should be empty
assert len(p1.discard.cards) == 0
# Process knockout (knocked_out_id, opponent_id who scores)
turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2")
# Energy should now be in discard
assert "ko-energy-1" in p1.discard
assert "ko-energy-2" in p1.discard
def test_attached_tools_go_to_discard_on_knockout(
self, knockout_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that attached tool cards go to the owner's discard pile on knockout.
"""
turn_manager = TurnManager()
p1 = knockout_game_state.players["player1"]
charmeleon = p1.get_active_pokemon()
assert len(charmeleon.attached_tools) == 1
turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2")
assert "ko-tool" in p1.discard
def test_evolution_stack_goes_to_discard_on_knockout(
self, knockout_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that the entire evolution stack goes to discard on knockout.
When Charmeleon is knocked out, both Charmeleon and the Charmander
underneath should go to the discard pile.
"""
turn_manager = TurnManager()
p1 = knockout_game_state.players["player1"]
charmeleon = p1.get_active_pokemon()
assert len(charmeleon.cards_underneath) == 1
turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2")
# Both the evolved Pokemon and the Basic underneath should be in discard
assert "ko-charmeleon" in p1.discard
assert "ko-charmander" in p1.discard
def test_all_attachments_discard_together_on_knockout(
self, knockout_game_state: GameState, seeded_rng: SeededRandom
) -> None:
"""
Test that all attachments (energy, tools, stack) go to discard on knockout.
Comprehensive test that energy, tools, and evolution stack all end up
in the discard pile when a Pokemon is knocked out.
"""
turn_manager = TurnManager()
p1 = knockout_game_state.players["player1"]
initial_discard_count = len(p1.discard.cards)
turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2")
# Should have added: 2 energy + 1 tool + 1 Pokemon underneath + 1 KO'd Pokemon = 5 cards
expected_cards_in_discard = initial_discard_count + 5
assert len(p1.discard.cards) == expected_cards_in_discard
# Verify all specific cards
assert "ko-energy-1" in p1.discard
assert "ko-energy-2" in p1.discard
assert "ko-tool" in p1.discard
assert "ko-charmander" in p1.discard
assert "ko-charmeleon" in p1.discard
# =============================================================================
# find_card_instance Tests for Attached Cards
# =============================================================================
class TestFindCardInstanceWithAttachments:
"""Tests for finding cards in attachments via find_card_instance."""
def test_find_attached_energy_by_id(
self, game_state: GameState, fire_energy_def: CardDefinition
) -> None:
"""
Test that find_card_instance can locate energy attached to a Pokemon.
Energy cards stored in attached_energy should be findable by their
instance_id.
"""
p1 = game_state.players["player1"]
charmander = p1.get_active_pokemon()
# Attach energy
energy = CardInstance(instance_id="findable-energy", definition_id=fire_energy_def.id)
charmander.attach_energy(energy)
# Find the energy
card, zone_name = game_state.find_card_instance("findable-energy")
assert card is not None
assert card.instance_id == "findable-energy"
assert zone_name == "attached_energy"
def test_find_attached_tool_by_id(
self, game_state: GameState, tool_def: CardDefinition
) -> None:
"""
Test that find_card_instance can locate tools attached to a Pokemon.
"""
p1 = game_state.players["player1"]
charmander = p1.get_active_pokemon()
# Attach tool
tool = CardInstance(instance_id="findable-tool", definition_id=tool_def.id)
charmander.attach_tool(tool)
# Find the tool
card, zone_name = game_state.find_card_instance("findable-tool")
assert card is not None
assert card.instance_id == "findable-tool"
assert zone_name == "attached_tools"
def test_find_card_in_evolution_stack(
self,
game_state: GameState,
basic_pokemon_def: CardDefinition,
stage1_pokemon_def: CardDefinition,
) -> None:
"""
Test that find_card_instance can locate cards in the evolution stack.
When searching for the Basic that's underneath a Stage 1, it should
be found in cards_underneath.
"""
p1 = game_state.players["player1"]
# Build evolved Pokemon manually
charmander = CardInstance(
instance_id="underneath-charmander",
definition_id=basic_pokemon_def.id,
)
charmeleon = CardInstance(
instance_id="evolved-charmeleon",
definition_id=stage1_pokemon_def.id,
cards_underneath=[charmander],
)
# Replace active with the evolved Pokemon
p1.active.clear()
p1.active.add(charmeleon)
# Find the Pokemon underneath
card, zone_name = game_state.find_card_instance("underneath-charmander")
assert card is not None
assert card.instance_id == "underneath-charmander"
assert zone_name == "cards_underneath"
def test_find_attached_card_on_bench_pokemon(
self,
game_state: GameState,
fire_energy_def: CardDefinition,
basic_pokemon_def: CardDefinition,
) -> None:
"""
Test finding attached cards on benched Pokemon.
Energy attached to bench Pokemon should also be findable.
"""
p1 = game_state.players["player1"]
# Add a bench Pokemon with energy
bench_pokemon = CardInstance(
instance_id="bench-pokemon",
definition_id=basic_pokemon_def.id,
)
energy = CardInstance(instance_id="bench-energy", definition_id=fire_energy_def.id)
bench_pokemon.attach_energy(energy)
p1.bench.add(bench_pokemon)
# Find the bench Pokemon's energy
card, zone_name = game_state.find_card_instance("bench-energy")
assert card is not None
assert card.instance_id == "bench-energy"
assert zone_name == "attached_energy"
def test_find_returns_none_for_nonexistent_card(self, game_state: GameState) -> None:
"""
Test that find_card_instance returns None for cards that don't exist.
"""
card, zone_name = game_state.find_card_instance("nonexistent-card-id")
assert card is None
assert zone_name is None
# =============================================================================
# CardInstance Method Tests
# =============================================================================
class TestCardInstanceAttachmentMethods:
"""Tests for CardInstance attachment methods."""
def test_attach_energy_adds_card_instance(self, fire_energy_def: CardDefinition) -> None:
"""
Test that attach_energy properly adds a CardInstance to attached_energy.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
energy = CardInstance(instance_id="energy-1", definition_id=fire_energy_def.id)
pokemon.attach_energy(energy)
assert len(pokemon.attached_energy) == 1
assert pokemon.attached_energy[0] is energy
assert pokemon.attached_energy[0].instance_id == "energy-1"
def test_detach_energy_removes_and_returns_card_instance(
self, fire_energy_def: CardDefinition
) -> None:
"""
Test that detach_energy removes and returns the CardInstance.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
energy1 = CardInstance(instance_id="energy-1", definition_id=fire_energy_def.id)
energy2 = CardInstance(instance_id="energy-2", definition_id=fire_energy_def.id)
pokemon.attach_energy(energy1)
pokemon.attach_energy(energy2)
assert len(pokemon.attached_energy) == 2
# Detach energy-1
detached = pokemon.detach_energy("energy-1")
assert detached is not None
assert detached.instance_id == "energy-1"
assert len(pokemon.attached_energy) == 1
assert pokemon.attached_energy[0].instance_id == "energy-2"
def test_detach_energy_returns_none_for_not_attached(self) -> None:
"""
Test that detach_energy returns None when the energy isn't attached.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
result = pokemon.detach_energy("nonexistent-energy")
assert result is None
def test_attach_tool_adds_card_instance(self, tool_def: CardDefinition) -> None:
"""
Test that attach_tool properly adds a CardInstance to attached_tools.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
tool = CardInstance(instance_id="tool-1", definition_id=tool_def.id)
pokemon.attach_tool(tool)
assert len(pokemon.attached_tools) == 1
assert pokemon.attached_tools[0] is tool
def test_detach_tool_removes_and_returns_card_instance(self, tool_def: CardDefinition) -> None:
"""
Test that detach_tool removes and returns the CardInstance.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
tool = CardInstance(instance_id="tool-1", definition_id=tool_def.id)
pokemon.attach_tool(tool)
detached = pokemon.detach_tool("tool-1")
assert detached is not None
assert detached.instance_id == "tool-1"
assert len(pokemon.attached_tools) == 0
def test_get_all_attached_cards_returns_all(
self, fire_energy_def: CardDefinition, tool_def: CardDefinition
) -> None:
"""
Test that get_all_attached_cards returns energy and tools.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
energy = CardInstance(instance_id="energy", definition_id=fire_energy_def.id)
tool = CardInstance(instance_id="tool", definition_id=tool_def.id)
pokemon.attach_energy(energy)
pokemon.attach_tool(tool)
all_attached = pokemon.get_all_attached_cards()
assert len(all_attached) == 2
ids = [c.instance_id for c in all_attached]
assert "energy" in ids
assert "tool" in ids
def test_multiple_energy_attachment(self, fire_energy_def: CardDefinition) -> None:
"""
Test that multiple energy cards can be attached.
"""
pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001")
for i in range(5):
energy = CardInstance(instance_id=f"energy-{i}", definition_id=fire_energy_def.id)
pokemon.attach_energy(energy)
assert len(pokemon.attached_energy) == 5
# Verify order is preserved
for i in range(5):
assert pokemon.attached_energy[i].instance_id == f"energy-{i}"