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