From fe2e1091f96e2cdd9d98fc28c883c5b924b20e7e Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 25 Jan 2026 13:34:42 -0600 Subject: [PATCH] Add comprehensive engine tests and fix action field name bugs Bug fixes in engine.py: - PlayPokemonAction: card_id -> card_instance_id - PlayTrainerAction: card_id -> card_instance_id - UseAbilityAction: pokemon_card_id -> pokemon_id - SelectActiveAction: card_id -> pokemon_id - record_ability_use() -> ability_uses_this_turn += 1 Added 26 new tests covering: - Energy deck setup (Pokemon Pocket style) - Prize card mode - Deck size validation (too large) - PlayPokemonAction (to active, to bench, not found) - EvolvePokemonAction (success, not in hand, target not found) - PlayTrainerAction (item, supporter, stadium, replacement) - UseAbilityAction (success, not found, invalid index) - SelectActiveAction (forced action, not on bench) - Deck-out on turn start - Attack edge cases (no energy, invalid index) - Retreat without bench - Attach energy from energy zone Test count: 711 -> 737 (+26) Coverage: 89% -> 94% overall, engine.py 55% -> 81% --- backend/app/core/engine.py | 32 +- backend/tests/core/test_engine.py | 1074 ++++++++++++++++++++++++++++- 2 files changed, 1089 insertions(+), 17 deletions(-) diff --git a/backend/app/core/engine.py b/backend/app/core/engine.py index 8a63b5c..54b1498 100644 --- a/backend/app/core/engine.py +++ b/backend/app/core/engine.py @@ -450,7 +450,7 @@ class GameEngine: action: PlayPokemonAction, ) -> ActionResult: """Execute playing a Basic Pokemon from hand to bench/active.""" - card = player.hand.remove(action.card_id) + card = player.hand.remove(action.card_instance_id) if not card: return ActionResult(success=False, message="Card not in hand") @@ -462,7 +462,7 @@ class GameEngine: success=True, message="Played Pokemon to active", state_changes=[ - {"type": "play_pokemon", "zone": "active", "card_id": action.card_id} + {"type": "play_pokemon", "zone": "active", "card_id": action.card_instance_id} ], ) @@ -472,7 +472,9 @@ class GameEngine: return ActionResult( success=True, message="Played Pokemon to bench", - state_changes=[{"type": "play_pokemon", "zone": "bench", "card_id": action.card_id}], + state_changes=[ + {"type": "play_pokemon", "zone": "bench", "card_id": action.card_instance_id} + ], ) def _execute_evolve( @@ -585,7 +587,7 @@ class GameEngine: action: PlayTrainerAction, ) -> ActionResult: """Execute playing a Trainer card.""" - card = player.hand.remove(action.card_id) + card = player.hand.remove(action.card_instance_id) if not card: return ActionResult(success=False, message="Card not in hand") @@ -618,7 +620,7 @@ class GameEngine: return ActionResult( success=True, message="Stadium played", - state_changes=[{"type": "play_stadium", "card_id": action.card_id}], + state_changes=[{"type": "play_stadium", "card_id": action.card_instance_id}], ) elif card_def.trainer_type == TrainerType.ITEM: player.items_played_this_turn += 1 @@ -630,7 +632,7 @@ class GameEngine: return ActionResult( success=True, message="Trainer card played", - state_changes=[{"type": "play_trainer", "card_id": action.card_id}], + state_changes=[{"type": "play_trainer", "card_id": action.card_instance_id}], ) async def _execute_use_ability( @@ -642,10 +644,10 @@ class GameEngine: """Execute using a Pokemon's ability.""" # Find Pokemon with ability pokemon = None - if action.pokemon_card_id in player.active: - pokemon = player.active.get(action.pokemon_card_id) - elif action.pokemon_card_id in player.bench: - pokemon = player.bench.get(action.pokemon_card_id) + if action.pokemon_id in player.active: + pokemon = player.active.get(action.pokemon_id) + elif action.pokemon_id in player.bench: + pokemon = player.bench.get(action.pokemon_id) if not pokemon: return ActionResult(success=False, message="Pokemon not found") @@ -659,15 +661,15 @@ class GameEngine: ability = card_def.abilities[action.ability_index] - # Mark ability as used - pokemon.record_ability_use(action.ability_index) + # Mark ability as used (increment counter) + pokemon.ability_uses_this_turn += 1 # Execute ability effect (placeholder - would use effect system) return ActionResult( success=True, message=f"Used ability: {ability.name}", state_changes=[ - {"type": "use_ability", "pokemon": action.pokemon_card_id, "ability": ability.name} + {"type": "use_ability", "pokemon": action.pokemon_id, "ability": ability.name} ], ) @@ -792,7 +794,7 @@ class GameEngine: ) -> ActionResult: """Execute selecting a new active Pokemon (forced action).""" # Move selected Pokemon from bench to active - new_active = player.bench.remove(action.card_id) + new_active = player.bench.remove(action.pokemon_id) if not new_active: return ActionResult(success=False, message="Pokemon not found on bench") @@ -804,7 +806,7 @@ class GameEngine: return ActionResult( success=True, message="New active Pokemon selected", - state_changes=[{"type": "select_active", "card_id": action.card_id}], + state_changes=[{"type": "select_active", "card_id": action.pokemon_id}], ) def _execute_resign( diff --git a/backend/tests/core/test_engine.py b/backend/tests/core/test_engine.py index 8046d8c..28ea4a5 100644 --- a/backend/tests/core/test_engine.py +++ b/backend/tests/core/test_engine.py @@ -20,20 +20,26 @@ from app.core.engine import GameEngine from app.core.models.actions import ( AttachEnergyAction, AttackAction, + EvolvePokemonAction, PassAction, + PlayPokemonAction, + PlayTrainerAction, ResignAction, RetreatAction, + SelectActiveAction, + UseAbilityAction, ) -from app.core.models.card import Attack, CardDefinition, CardInstance +from app.core.models.card import Ability, Attack, CardDefinition, CardInstance from app.core.models.enums import ( CardType, EnergyType, GameEndReason, PokemonStage, PokemonVariant, + TrainerType, TurnPhase, ) -from app.core.models.game_state import GameState +from app.core.models.game_state import ForcedAction, GameState from app.core.rng import SeededRandom # ============================================================================= @@ -842,3 +848,1067 @@ class TestIntegrationScenarios: # Verify turn advanced assert game.current_player_id == "player2" + + +# ============================================================================= +# Game Creation - Energy Deck and Prize Card Tests +# ============================================================================= + + +class TestGameCreationAdvanced: + """Tests for advanced game creation features.""" + + @pytest.fixture + def energy_deck(self, energy_def: CardDefinition) -> list[CardInstance]: + """Create an energy deck for Pokemon Pocket style energy.""" + return [ + CardInstance(instance_id=f"edeck-energy-{i}", definition_id=energy_def.id) + for i in range(20) + ] + + def test_create_game_with_energy_deck( + self, + seeded_rng: SeededRandom, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + energy_deck: list[CardInstance], + ): + """ + Test game creation with separate energy decks (Pokemon Pocket style). + + Verifies energy decks are shuffled and assigned to each player's + energy_deck zone for the flip-to-gain mechanic. + """ + engine = GameEngine(rules=RulesConfig(), rng=seeded_rng) + + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + energy_decks={ + "player1": list(energy_deck), + "player2": [ + CardInstance(instance_id=f"p2-edeck-{i}", definition_id="lightning-energy") + for i in range(20) + ], + }, + ) + + assert result.success + game = result.game + # Energy decks should be populated + assert len(game.players["player1"].energy_deck) == 20 + assert len(game.players["player2"].energy_deck) == 20 + + def test_create_game_with_prize_cards( + self, + seeded_rng: SeededRandom, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + ): + """ + Test game creation with prize card mode enabled. + + Verifies prize cards are dealt from the deck to the prizes zone. + """ + rules = RulesConfig() + rules.prizes.use_prize_cards = True + rules.prizes.count = 6 + engine = GameEngine(rules=rules, rng=seeded_rng) + + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + ) + + assert result.success + game = result.game + # Prize cards should be dealt + assert len(game.players["player1"].prizes) == 6 + assert len(game.players["player2"].prizes) == 6 + + def test_create_game_deck_too_large( + self, + engine: GameEngine, + basic_pokemon_def: CardDefinition, + energy_def: CardDefinition, + card_registry: dict[str, CardDefinition], + ): + """ + Test that game creation fails with oversized deck. + + Default max deck size is 60, so 70 cards should fail. + """ + large_deck = [] + for i in range(10): + large_deck.append( + CardInstance(instance_id=f"pokemon-{i}", definition_id=basic_pokemon_def.id) + ) + for i in range(60): + large_deck.append(CardInstance(instance_id=f"energy-{i}", definition_id=energy_def.id)) + + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": large_deck, "player2": large_deck}, + card_registry=card_registry, + ) + + assert not result.success + assert "too large" in result.message + + +# ============================================================================= +# Action Execution - Play Pokemon Tests +# ============================================================================= + + +class TestPlayPokemonAction: + """Tests for playing Pokemon from hand to field.""" + + @pytest.fixture + def game_for_pokemon( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + ) -> GameState: + """Create a game ready for playing Pokemon.""" + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + ) + game = result.game + + # Player 1 has no active yet (clear setup) + p1 = game.players["player1"] + p1.active.cards.clear() + + # Add a basic Pokemon to hand + basic = CardInstance(instance_id="hand-pikachu", definition_id=basic_pokemon_def.id) + p1.hand.add(basic) + + # Player 2 has active + p2 = game.players["player2"] + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + game.phase = TurnPhase.MAIN + game.turn_number = 2 + return game + + @pytest.mark.asyncio + async def test_play_pokemon_to_active( + self, + engine: GameEngine, + game_for_pokemon: GameState, + ): + """ + Test playing a Basic Pokemon to active when no active exists. + + Pokemon should be placed in the active zone and marked with turn played. + """ + action = PlayPokemonAction(card_instance_id="hand-pikachu") + result = await engine.execute_action(game_for_pokemon, "player1", action) + + assert result.success + assert "active" in result.message.lower() + + # Verify Pokemon is now active + active = game_for_pokemon.players["player1"].get_active_pokemon() + assert active is not None + assert active.instance_id == "hand-pikachu" + assert active.turn_played == game_for_pokemon.turn_number + + @pytest.mark.asyncio + async def test_play_pokemon_to_bench( + self, + engine: GameEngine, + game_for_pokemon: GameState, + basic_pokemon_def: CardDefinition, + ): + """ + Test playing a Basic Pokemon to bench when active exists. + + Pokemon should be placed on the bench when player already has an active. + """ + # Give player an active first + p1 = game_for_pokemon.players["player1"] + p1.active.add( + CardInstance(instance_id="existing-active", definition_id=basic_pokemon_def.id) + ) + + action = PlayPokemonAction(card_instance_id="hand-pikachu") + result = await engine.execute_action(game_for_pokemon, "player1", action) + + assert result.success + assert "bench" in result.message.lower() + + # Verify Pokemon is on bench + assert "hand-pikachu" in p1.bench + + @pytest.mark.asyncio + async def test_play_pokemon_card_not_in_hand( + self, + engine: GameEngine, + game_for_pokemon: GameState, + ): + """ + Test that playing a non-existent card fails. + """ + action = PlayPokemonAction(card_instance_id="nonexistent-card") + result = await engine.execute_action(game_for_pokemon, "player1", action) + + assert not result.success + + +# ============================================================================= +# Action Execution - Evolve Pokemon Tests +# ============================================================================= + + +class TestEvolvePokemonAction: + """Tests for evolving Pokemon.""" + + @pytest.fixture + def evolution_card_def(self, basic_pokemon_def: CardDefinition) -> CardDefinition: + """Create a Stage 1 evolution card that evolves from basic_pokemon_def.""" + return CardDefinition( + id="raichu-evo", + name="Raichu", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_1, + variant=PokemonVariant.NORMAL, + hp=100, + evolves_from=basic_pokemon_def.name, # Must match the Pokemon's name + attacks=[ + Attack( + name="Thunder", + damage=80, + cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING], + ), + ], + retreat_cost=2, + ) + + @pytest.fixture + def game_for_evolution( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + evolution_card_def: CardDefinition, + ) -> tuple[GameState, dict[str, CardDefinition]]: + """Create a game ready for evolution testing.""" + # Add evolution card to registry + registry = dict(card_registry) + registry[evolution_card_def.id] = evolution_card_def + + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + # Player 1: active Pikachu (played last turn) with energy and damage + active = CardInstance(instance_id="active-pikachu", definition_id=basic_pokemon_def.id) + active.turn_played = 1 # Played last turn, can evolve + active.damage = 20 + active.attach_energy("attached-energy-1") + p1.active.add(active) + + # Player 1: Raichu in hand + evo = CardInstance(instance_id="hand-raichu", definition_id=evolution_card_def.id) + p1.hand.add(evo) + + # Player 2 active + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + game.phase = TurnPhase.MAIN + game.turn_number = 2 # Not first turn + return game, registry + + @pytest.mark.asyncio + async def test_evolve_pokemon_success( + self, + engine: GameEngine, + game_for_evolution: tuple[GameState, dict], + ): + """ + Test successfully evolving a Pokemon. + + Evolution should transfer energy and damage from the base Pokemon. + """ + game, registry = game_for_evolution + + action = EvolvePokemonAction( + evolution_card_id="hand-raichu", + target_pokemon_id="active-pikachu", + ) + result = await engine.execute_action(game, "player1", action) + + assert result.success + assert "evolved" in result.message.lower() + + # Verify evolution happened + active = game.players["player1"].get_active_pokemon() + assert active.instance_id == "hand-raichu" + assert active.definition_id == "raichu-evo" + + # Verify energy and damage transferred + assert "attached-energy-1" in active.attached_energy + assert active.damage == 20 + + # Verify old Pokemon went to discard + assert "active-pikachu" in game.players["player1"].discard + + @pytest.mark.asyncio + async def test_evolve_pokemon_not_in_hand( + self, + engine: GameEngine, + game_for_evolution: tuple[GameState, dict], + ): + """ + Test that evolving with a card not in hand fails. + """ + game, _ = game_for_evolution + + action = EvolvePokemonAction( + evolution_card_id="nonexistent-raichu", + target_pokemon_id="active-pikachu", + ) + result = await engine.execute_action(game, "player1", action) + + assert not result.success + assert "not found in hand" in result.message.lower() + + @pytest.mark.asyncio + async def test_evolve_pokemon_target_not_found( + self, + engine: GameEngine, + game_for_evolution: tuple[GameState, dict], + ): + """ + Test that evolving a non-existent target fails. + """ + game, _ = game_for_evolution + + action = EvolvePokemonAction( + evolution_card_id="hand-raichu", + target_pokemon_id="nonexistent-pikachu", + ) + result = await engine.execute_action(game, "player1", action) + + assert not result.success + # Evolution card should be returned to hand + assert "hand-raichu" in game.players["player1"].hand + + +# ============================================================================= +# Action Execution - Play Trainer Tests +# ============================================================================= + + +class TestPlayTrainerAction: + """Tests for playing Trainer cards.""" + + @pytest.fixture + def item_card_def(self) -> CardDefinition: + """Create an Item trainer card.""" + return CardDefinition( + id="potion-001", + name="Potion", + card_type=CardType.TRAINER, + trainer_type=TrainerType.ITEM, + ) + + @pytest.fixture + def supporter_card_def(self) -> CardDefinition: + """Create a Supporter trainer card.""" + return CardDefinition( + id="professor-001", + name="Professor's Research", + card_type=CardType.TRAINER, + trainer_type=TrainerType.SUPPORTER, + ) + + @pytest.fixture + def stadium_card_def(self) -> CardDefinition: + """Create a Stadium trainer card.""" + return CardDefinition( + id="stadium-001", + name="Training Court", + card_type=CardType.TRAINER, + trainer_type=TrainerType.STADIUM, + ) + + @pytest.fixture + def game_for_trainer( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + item_card_def: CardDefinition, + supporter_card_def: CardDefinition, + stadium_card_def: CardDefinition, + ) -> tuple[GameState, dict[str, CardDefinition]]: + """Create a game ready for trainer card testing.""" + registry = dict(card_registry) + registry[item_card_def.id] = item_card_def + registry[supporter_card_def.id] = supporter_card_def + registry[stadium_card_def.id] = stadium_card_def + + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + # Active Pokemon + p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)) + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + # Add trainers to hand + p1.hand.add(CardInstance(instance_id="hand-potion", definition_id=item_card_def.id)) + p1.hand.add(CardInstance(instance_id="hand-professor", definition_id=supporter_card_def.id)) + p1.hand.add(CardInstance(instance_id="hand-stadium", definition_id=stadium_card_def.id)) + + game.phase = TurnPhase.MAIN + game.turn_number = 2 + return game, registry + + @pytest.mark.asyncio + async def test_play_item_card( + self, + engine: GameEngine, + game_for_trainer: tuple[GameState, dict], + ): + """ + Test playing an Item trainer card. + + Item cards should be played and discarded, incrementing the item counter. + """ + game, _ = game_for_trainer + p1 = game.players["player1"] + + action = PlayTrainerAction(card_instance_id="hand-potion") + result = await engine.execute_action(game, "player1", action) + + assert result.success + assert "Trainer card played" in result.message + + # Card should be discarded + assert "hand-potion" in p1.discard + assert "hand-potion" not in p1.hand + + # Counter should be incremented + assert p1.items_played_this_turn == 1 + + @pytest.mark.asyncio + async def test_play_supporter_card( + self, + engine: GameEngine, + game_for_trainer: tuple[GameState, dict], + ): + """ + Test playing a Supporter trainer card. + + Supporter cards increment the supporter counter (limited per turn). + """ + game, _ = game_for_trainer + p1 = game.players["player1"] + + action = PlayTrainerAction(card_instance_id="hand-professor") + result = await engine.execute_action(game, "player1", action) + + assert result.success + assert p1.supporters_played_this_turn == 1 + + @pytest.mark.asyncio + async def test_play_stadium_card( + self, + engine: GameEngine, + game_for_trainer: tuple[GameState, dict], + ): + """ + Test playing a Stadium trainer card. + + Stadium cards go into play (not discarded) and replace existing stadiums. + """ + game, _ = game_for_trainer + + action = PlayTrainerAction(card_instance_id="hand-stadium") + result = await engine.execute_action(game, "player1", action) + + assert result.success + assert "Stadium played" in result.message + + # Stadium should be in play + assert game.stadium_in_play is not None + assert game.stadium_in_play.instance_id == "hand-stadium" + + @pytest.fixture + def different_stadium_def(self) -> CardDefinition: + """Create a different stadium card for replacement tests.""" + return CardDefinition( + id="stadium-002", + name="Power Plant", + card_type=CardType.TRAINER, + trainer_type=TrainerType.STADIUM, + ) + + @pytest.mark.asyncio + async def test_play_stadium_replaces_existing( + self, + engine: GameEngine, + game_for_trainer: tuple[GameState, dict], + stadium_card_def: CardDefinition, + different_stadium_def: CardDefinition, + ): + """ + Test that playing a new Stadium replaces the existing one. + + The old stadium should be discarded. + """ + game, _ = game_for_trainer + # Add the different stadium to the game's card registry + game.card_registry[different_stadium_def.id] = different_stadium_def + p1 = game.players["player1"] + + # Put an existing stadium in play + old_stadium = CardInstance(instance_id="old-stadium", definition_id=stadium_card_def.id) + game.stadium_in_play = old_stadium + + # Add a different stadium to hand (different from old one) + new_stadium = CardInstance( + instance_id="new-stadium", definition_id=different_stadium_def.id + ) + p1.hand.add(new_stadium) + + action = PlayTrainerAction(card_instance_id="new-stadium") + result = await engine.execute_action(game, "player1", action) + + assert result.success + # New stadium should be in play + assert game.stadium_in_play.instance_id == "new-stadium" + # Old stadium should be discarded + assert "old-stadium" in p1.discard + + @pytest.mark.asyncio + async def test_play_trainer_card_not_found( + self, + engine: GameEngine, + game_for_trainer: tuple[GameState, dict], + ): + """ + Test that playing a non-existent trainer card fails. + """ + game, _ = game_for_trainer + + action = PlayTrainerAction(card_instance_id="nonexistent-trainer") + result = await engine.execute_action(game, "player1", action) + + assert not result.success + + +# ============================================================================= +# Action Execution - Use Ability Tests +# ============================================================================= + + +class TestUseAbilityAction: + """Tests for using Pokemon abilities.""" + + @pytest.fixture + def pokemon_with_ability_def(self) -> CardDefinition: + """Create a Pokemon with an ability.""" + return CardDefinition( + id="pikachu-ability", + name="Pikachu", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.NORMAL, + hp=60, + abilities=[ + Ability( + name="Static", + description="Flip a coin. If heads, your opponent's Active Pokemon is Paralyzed.", + effect_id="paralyze_on_flip", + uses_per_turn=1, + ), + ], + attacks=[ + Attack(name="Thunder Shock", damage=20, cost=[EnergyType.LIGHTNING]), + ], + retreat_cost=1, + ) + + @pytest.fixture + def game_for_ability( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + pokemon_with_ability_def: CardDefinition, + ) -> tuple[GameState, dict[str, CardDefinition]]: + """Create a game ready for ability testing.""" + registry = dict(card_registry) + registry[pokemon_with_ability_def.id] = pokemon_with_ability_def + + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + # Player 1 has Pokemon with ability as active + active = CardInstance( + instance_id="ability-pikachu", definition_id=pokemon_with_ability_def.id + ) + p1.active.add(active) + + # Player 2 has active + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + game.phase = TurnPhase.MAIN + game.turn_number = 2 + return game, registry + + @pytest.mark.asyncio + async def test_use_ability_success( + self, + engine: GameEngine, + game_for_ability: tuple[GameState, dict], + ): + """ + Test successfully using a Pokemon's ability. + + Ability should be marked as used. + """ + game, _ = game_for_ability + + action = UseAbilityAction( + pokemon_id="ability-pikachu", + ability_index=0, + ) + result = await engine.execute_action(game, "player1", action) + + assert result.success + assert "Static" in result.message + + # Ability should be recorded as used + active = game.players["player1"].get_active_pokemon() + assert active.ability_uses_this_turn >= 1 + + @pytest.mark.asyncio + async def test_use_ability_pokemon_not_found( + self, + engine: GameEngine, + game_for_ability: tuple[GameState, dict], + ): + """ + Test that using ability on non-existent Pokemon fails. + """ + game, _ = game_for_ability + + action = UseAbilityAction( + pokemon_id="nonexistent-pokemon", + ability_index=0, + ) + result = await engine.execute_action(game, "player1", action) + + assert not result.success + assert "not found" in result.message.lower() + + @pytest.mark.asyncio + async def test_use_ability_invalid_index( + self, + engine: GameEngine, + game_for_ability: tuple[GameState, dict], + ): + """ + Test that using an invalid ability index fails. + """ + game, _ = game_for_ability + + action = UseAbilityAction( + pokemon_id="ability-pikachu", + ability_index=5, # Invalid index + ) + result = await engine.execute_action(game, "player1", action) + + assert not result.success + assert "Invalid ability index" in result.message + + +# ============================================================================= +# Action Execution - Select Active Tests (Forced Action) +# ============================================================================= + + +class TestSelectActiveAction: + """Tests for selecting a new active Pokemon (forced action after KO).""" + + @pytest.fixture + def game_with_forced_action( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + ) -> GameState: + """Create a game with a forced action to select a new active.""" + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + # Player 1 has no active (was KO'd) but has bench + p1.bench.add(CardInstance(instance_id="p1-bench-1", definition_id=basic_pokemon_def.id)) + p1.bench.add(CardInstance(instance_id="p1-bench-2", definition_id=basic_pokemon_def.id)) + + # Player 2 has active + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + # Set forced action + game.forced_action = ForcedAction( + player_id="player1", + action_type="select_active", + reason="Active Pokemon was knocked out", + ) + + game.phase = TurnPhase.MAIN + game.turn_number = 2 + return game + + @pytest.mark.asyncio + async def test_select_active_success( + self, + engine: GameEngine, + game_with_forced_action: GameState, + ): + """ + Test successfully selecting a new active Pokemon. + + The selected Pokemon should move from bench to active and forced action cleared. + """ + action = SelectActiveAction(pokemon_id="p1-bench-1") + result = await engine.execute_action(game_with_forced_action, "player1", action) + + assert result.success + assert "New active" in result.message + + # Pokemon should now be active + p1 = game_with_forced_action.players["player1"] + active = p1.get_active_pokemon() + assert active is not None + assert active.instance_id == "p1-bench-1" + + # Should not be on bench anymore + assert "p1-bench-1" not in p1.bench + + # Forced action should be cleared + assert game_with_forced_action.forced_action is None + + @pytest.mark.asyncio + async def test_select_active_not_on_bench( + self, + engine: GameEngine, + game_with_forced_action: GameState, + ): + """ + Test that selecting a Pokemon not on bench fails. + """ + action = SelectActiveAction(pokemon_id="nonexistent-pokemon") + result = await engine.execute_action(game_with_forced_action, "player1", action) + + assert not result.success + assert "not found on bench" in result.message.lower() + + +# ============================================================================= +# Deck-Out and Win Condition Edge Cases +# ============================================================================= + + +class TestDeckOutAndEdgeCases: + """Tests for deck-out and edge case scenarios.""" + + @pytest.fixture + def game_about_to_deck_out( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + ) -> GameState: + """Create a game where player1 has an empty deck (will deck out on draw).""" + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + # Player 1 has empty deck + p1.deck.cards.clear() + + # Both have actives + p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)) + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + game.phase = TurnPhase.SETUP + game.turn_number = 5 + return game + + def test_deck_out_on_turn_start( + self, + engine: GameEngine, + game_about_to_deck_out: GameState, + ): + """ + Test that drawing with an empty deck triggers loss. + + When a player cannot draw at turn start, they lose the game. + """ + result = engine.start_turn(game_about_to_deck_out) + + # Turn start should fail due to deck out + assert not result.success + assert result.win_result is not None + assert result.win_result.winner_id == "player2" + assert result.win_result.end_reason == GameEndReason.DECK_EMPTY + + @pytest.fixture + def game_for_attack_edge_cases( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + ) -> GameState: + """Create a game for testing attack edge cases.""" + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)) + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + game.phase = TurnPhase.ATTACK + game.turn_number = 2 + return game + + @pytest.mark.asyncio + async def test_attack_without_energy( + self, + engine: GameEngine, + game_for_attack_edge_cases: GameState, + ): + """ + Test that attacking without required energy fails validation. + """ + action = AttackAction(attack_index=0) + result = await engine.execute_action(game_for_attack_edge_cases, "player1", action) + + # Should fail validation due to insufficient energy + assert not result.success + + @pytest.mark.asyncio + async def test_attack_invalid_index( + self, + engine: GameEngine, + game_for_attack_edge_cases: GameState, + energy_def: CardDefinition, + ): + """ + Test that using an invalid attack index fails. + """ + # Attach energy so energy check passes + p1 = game_for_attack_edge_cases.players["player1"] + energy = CardInstance(instance_id="test-energy", definition_id=energy_def.id) + p1.discard.add(energy) + p1.get_active_pokemon().attach_energy(energy.instance_id) + + action = AttackAction(attack_index=99) # Invalid index + result = await engine.execute_action(game_for_attack_edge_cases, "player1", action) + + assert not result.success + + @pytest.mark.asyncio + async def test_retreat_without_bench( + self, + engine: GameEngine, + game_for_attack_edge_cases: GameState, + ): + """ + Test that retreating without a bench Pokemon fails. + """ + game_for_attack_edge_cases.phase = TurnPhase.MAIN + + action = RetreatAction( + new_active_id="nonexistent", + energy_to_discard=[], + ) + result = await engine.execute_action(game_for_attack_edge_cases, "player1", action) + + assert not result.success + + +# ============================================================================= +# Attach Energy - Energy Zone Tests +# ============================================================================= + + +class TestAttachEnergyFromEnergyZone: + """Tests for attaching energy from energy zone (Pokemon Pocket style).""" + + @pytest.fixture + def game_with_energy_zone( + self, + engine: GameEngine, + player1_deck: list[CardInstance], + player2_deck: list[CardInstance], + card_registry: dict[str, CardDefinition], + basic_pokemon_def: CardDefinition, + energy_def: CardDefinition, + ) -> GameState: + """Create a game with energy in the energy zone.""" + result = engine.create_game( + player_ids=["player1", "player2"], + decks={"player1": player1_deck, "player2": player2_deck}, + card_registry=card_registry, + ) + game = result.game + + p1 = game.players["player1"] + p2 = game.players["player2"] + + # Active Pokemon + p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)) + p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)) + + # Energy in energy zone (flipped from energy deck) + energy = CardInstance(instance_id="zone-energy", definition_id=energy_def.id) + p1.energy_zone.add(energy) + + game.phase = TurnPhase.MAIN + game.turn_number = 2 + return game + + @pytest.mark.asyncio + async def test_attach_energy_from_zone( + self, + engine: GameEngine, + game_with_energy_zone: GameState, + ): + """ + Test attaching energy from the energy zone. + + Energy zone is used in Pokemon Pocket style where energy is flipped + from a separate deck into a zone, then attached from there. + """ + action = AttachEnergyAction( + energy_card_id="zone-energy", + target_pokemon_id="p1-active", + from_energy_zone=True, + ) + result = await engine.execute_action(game_with_energy_zone, "player1", action) + + assert result.success + + # Energy should be attached to active + p1 = game_with_energy_zone.players["player1"] + active = p1.get_active_pokemon() + assert "zone-energy" in active.attached_energy + + # Energy zone should be empty + assert "zone-energy" not in p1.energy_zone + + @pytest.mark.asyncio + async def test_attach_energy_to_bench( + self, + engine: GameEngine, + game_with_energy_zone: GameState, + basic_pokemon_def: CardDefinition, + ): + """ + Test attaching energy to a bench Pokemon. + """ + # Add a bench Pokemon + p1 = game_with_energy_zone.players["player1"] + p1.bench.add(CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id)) + + action = AttachEnergyAction( + energy_card_id="zone-energy", + target_pokemon_id="p1-bench", + from_energy_zone=True, + ) + result = await engine.execute_action(game_with_energy_zone, "player1", action) + + assert result.success + + # Energy should be attached to bench Pokemon + bench_pokemon = p1.bench.get("p1-bench") + assert "zone-energy" in bench_pokemon.attached_energy + + @pytest.mark.asyncio + async def test_attach_energy_target_not_found( + self, + engine: GameEngine, + game_with_energy_zone: GameState, + ): + """ + Test that attaching energy to non-existent Pokemon fails and returns energy. + """ + action = AttachEnergyAction( + energy_card_id="zone-energy", + target_pokemon_id="nonexistent-pokemon", + from_energy_zone=True, + ) + result = await engine.execute_action(game_with_energy_zone, "player1", action) + + assert not result.success + + # Energy should be returned to zone + p1 = game_with_energy_zone.players["player1"] + assert "zone-energy" in p1.energy_zone