"""Tests for the turn manager module. This module tests: - Phase transition validation - Turn start processing (draw, energy flip, counter resets) - Turn end processing (status damage, recovery, turn advancement) - Between-turn effects (poison, burn, sleep, paralysis) - Knockout processing and win condition integration - Turn limit checking The tests use SeededRandom for deterministic coin flips. """ import pytest from app.core.config import ( RulesConfig, ) from app.core.models.card import CardDefinition, CardInstance from app.core.models.enums import ( CardType, EnergyType, GameEndReason, PokemonStage, PokemonVariant, StatusCondition, TurnPhase, ) from app.core.models.game_state import GameState, PlayerState from app.core.rng import SeededRandom from app.core.turn_manager import ( VALID_TRANSITIONS, PhaseTransitionError, TurnEndResult, TurnManager, ) # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def basic_pokemon_def() -> CardDefinition: """Create a basic Pokemon card definition.""" return CardDefinition( id="pikachu-001", name="Pikachu", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, variant=PokemonVariant.NORMAL, hp=60, pokemon_type=EnergyType.LIGHTNING, ) @pytest.fixture def ex_pokemon_def() -> CardDefinition: """Create an EX Pokemon card definition (worth 2 points).""" return CardDefinition( id="pikachu-ex-001", name="Pikachu EX", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, variant=PokemonVariant.EX, hp=120, pokemon_type=EnergyType.LIGHTNING, ) @pytest.fixture def energy_def() -> CardDefinition: """Create a basic energy card definition.""" return CardDefinition( id="lightning-energy-001", name="Lightning Energy", card_type=CardType.ENERGY, energy_type=EnergyType.LIGHTNING, ) @pytest.fixture def two_player_game(basic_pokemon_def: CardDefinition, energy_def: CardDefinition) -> GameState: """Create a minimal two-player game state for testing.""" # Create card instances p1_active = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id) p1_bench = CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id) p1_deck_cards = [ CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id) for i in range(5) ] p1_energy_deck = [ CardInstance(instance_id=f"p1-energy-{i}", definition_id=energy_def.id) for i in range(5) ] p2_active = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id) p2_deck_cards = [ CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id) for i in range(5) ] # Create player states p1 = PlayerState(player_id="player1") p1.active.add(p1_active) p1.bench.add(p1_bench) for card in p1_deck_cards: p1.deck.add(card) for card in p1_energy_deck: p1.energy_deck.add(card) p2 = PlayerState(player_id="player2") p2.active.add(p2_active) for card in p2_deck_cards: p2.deck.add(card) # Create game state game = GameState( game_id="test-game", rules=RulesConfig(), card_registry={ basic_pokemon_def.id: basic_pokemon_def, energy_def.id: energy_def, }, players={"player1": p1, "player2": p2}, current_player_id="player1", turn_number=1, phase=TurnPhase.SETUP, turn_order=["player1", "player2"], ) return game @pytest.fixture def turn_manager() -> TurnManager: """Create a TurnManager instance.""" return TurnManager() @pytest.fixture def seeded_rng() -> SeededRandom: """Create a seeded RNG for deterministic tests.""" return SeededRandom(seed=42) # ============================================================================= # Phase Transition Tests # ============================================================================= class TestPhaseTransitions: """Tests for phase transition validation.""" def test_valid_transitions_defined(self): """ Test that all phases have valid transitions defined. Ensures the VALID_TRANSITIONS constant is complete and covers all phases in the game. """ for phase in TurnPhase: assert phase in VALID_TRANSITIONS, f"Missing transitions for {phase}" def test_setup_to_draw_valid(self, turn_manager: TurnManager): """ Test that SETUP -> DRAW is a valid transition. This is the game start transition. """ assert turn_manager.can_transition(TurnPhase.SETUP, TurnPhase.DRAW) def test_draw_to_main_valid(self, turn_manager: TurnManager): """ Test that DRAW -> MAIN is a valid transition. This is the normal flow after drawing a card. """ assert turn_manager.can_transition(TurnPhase.DRAW, TurnPhase.MAIN) def test_main_to_attack_valid(self, turn_manager: TurnManager): """ Test that MAIN -> ATTACK is a valid transition. This occurs when the player declares an attack. """ assert turn_manager.can_transition(TurnPhase.MAIN, TurnPhase.ATTACK) def test_main_to_end_valid(self, turn_manager: TurnManager): """ Test that MAIN -> END is a valid transition. This occurs when the player chooses to skip attacking. """ assert turn_manager.can_transition(TurnPhase.MAIN, TurnPhase.END) def test_attack_to_end_valid(self, turn_manager: TurnManager): """ Test that ATTACK -> END is a valid transition. This occurs after attack resolution. """ assert turn_manager.can_transition(TurnPhase.ATTACK, TurnPhase.END) def test_end_to_draw_valid(self, turn_manager: TurnManager): """ Test that END -> DRAW is a valid transition. This occurs when advancing to the next player's turn. """ assert turn_manager.can_transition(TurnPhase.END, TurnPhase.DRAW) def test_invalid_setup_to_main(self, turn_manager: TurnManager): """ Test that SETUP -> MAIN is invalid. Cannot skip the draw phase. """ assert not turn_manager.can_transition(TurnPhase.SETUP, TurnPhase.MAIN) def test_invalid_draw_to_attack(self, turn_manager: TurnManager): """ Test that DRAW -> ATTACK is invalid. Cannot skip the main phase. """ assert not turn_manager.can_transition(TurnPhase.DRAW, TurnPhase.ATTACK) def test_invalid_attack_to_main(self, turn_manager: TurnManager): """ Test that ATTACK -> MAIN is invalid. Cannot go backwards in phase order. """ assert not turn_manager.can_transition(TurnPhase.ATTACK, TurnPhase.MAIN) def test_get_valid_transitions_from_main(self, turn_manager: TurnManager): """ Test getting valid transitions from MAIN phase. MAIN phase can go to ATTACK or END. """ valid = turn_manager.get_valid_transitions(TurnPhase.MAIN) assert TurnPhase.ATTACK in valid assert TurnPhase.END in valid assert len(valid) == 2 def test_set_phase_valid_transition( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test that _set_phase works for valid transitions. Verifies the phase is changed when transition is valid. """ two_player_game.phase = TurnPhase.MAIN turn_manager._set_phase(two_player_game, TurnPhase.ATTACK) assert two_player_game.phase == TurnPhase.ATTACK def test_set_phase_invalid_transition_raises( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test that _set_phase raises for invalid transitions. Verifies PhaseTransitionError is raised for invalid transitions. """ two_player_game.phase = TurnPhase.DRAW with pytest.raises(PhaseTransitionError) as exc_info: turn_manager._set_phase(two_player_game, TurnPhase.ATTACK) assert "Invalid phase transition" in str(exc_info.value) assert "draw -> attack" in str(exc_info.value).lower() # ============================================================================= # Turn Start Tests # ============================================================================= class TestTurnStart: """Tests for turn start processing.""" def test_start_turn_resets_counters( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that start_turn resets per-turn counters. Ensures energy attachments, supporter plays, etc. are reset. """ player = two_player_game.get_current_player() player.energy_attachments_this_turn = 3 player.supporters_played_this_turn = 1 player.retreats_this_turn = 2 two_player_game.phase = TurnPhase.SETUP turn_manager.start_turn(two_player_game, seeded_rng) assert player.energy_attachments_this_turn == 0 assert player.supporters_played_this_turn == 0 assert player.retreats_this_turn == 0 def test_start_turn_draws_card( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that start_turn draws a card from deck to hand. Verifies deck decreases and hand increases by one. """ two_player_game.phase = TurnPhase.SETUP two_player_game.turn_number = 2 # Not first turn player = two_player_game.get_current_player() initial_deck = len(player.deck) initial_hand = len(player.hand) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert result.drew_card assert len(player.deck) == initial_deck - 1 assert len(player.hand) == initial_hand + 1 def test_start_turn_first_turn_with_draw( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that first turn draws a card when rules allow. Default rules have can_draw=True for first turn. """ two_player_game.phase = TurnPhase.SETUP two_player_game.turn_number = 1 two_player_game.first_turn_completed = False player = two_player_game.get_current_player() initial_hand = len(player.hand) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert result.drew_card assert len(player.hand) == initial_hand + 1 def test_start_turn_first_turn_no_draw( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that first turn doesn't draw when rules disallow. Some variants don't allow drawing on first turn. """ two_player_game.rules.first_turn.can_draw = False two_player_game.phase = TurnPhase.SETUP two_player_game.turn_number = 1 two_player_game.first_turn_completed = False player = two_player_game.get_current_player() initial_hand = len(player.hand) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert not result.drew_card assert len(player.hand) == initial_hand assert "First turn - no draw" in result.message def test_start_turn_flips_energy( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that start_turn flips energy from energy deck to zone. Pokemon Pocket style auto-energy feature. """ two_player_game.phase = TurnPhase.SETUP two_player_game.turn_number = 2 # Not first turn player = two_player_game.get_current_player() initial_energy_deck = len(player.energy_deck) initial_energy_zone = len(player.energy_zone) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert result.energy_flipped assert len(player.energy_deck) == initial_energy_deck - 1 assert len(player.energy_zone) == initial_energy_zone + 1 def test_start_turn_no_energy_flip_when_disabled( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that energy is not flipped when auto_flip_from_deck is disabled. Standard Pokemon TCG doesn't use energy zone. """ two_player_game.rules.energy.auto_flip_from_deck = False two_player_game.phase = TurnPhase.SETUP two_player_game.turn_number = 2 player = two_player_game.get_current_player() initial_energy_deck = len(player.energy_deck) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert not result.energy_flipped assert len(player.energy_deck) == initial_energy_deck def test_start_turn_no_energy_flip_first_turn( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that energy is not flipped on first turn when disallowed. Default rules have can_attach_energy=False for first turn. """ two_player_game.phase = TurnPhase.SETUP two_player_game.turn_number = 1 two_player_game.first_turn_completed = False player = two_player_game.get_current_player() initial_energy_deck = len(player.energy_deck) initial_energy_zone = len(player.energy_zone) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert not result.energy_flipped assert len(player.energy_deck) == initial_energy_deck assert len(player.energy_zone) == initial_energy_zone def test_start_turn_advances_to_main( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that start_turn advances phase to MAIN. Verifies the automatic phase progression. """ two_player_game.phase = TurnPhase.SETUP turn_manager.start_turn(two_player_game, seeded_rng) assert two_player_game.phase == TurnPhase.MAIN def test_start_turn_deck_empty_triggers_win( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that start_turn returns win result when deck is empty. Cannot draw = opponent wins. """ two_player_game.phase = TurnPhase.SETUP player = two_player_game.get_current_player() player.deck.clear() result = turn_manager.start_turn(two_player_game, seeded_rng) 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 def test_start_turn_deck_empty_check_disabled( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that deck empty doesn't trigger win when condition disabled. Some game variants may not use deck-out as a win condition. """ two_player_game.rules.win_conditions.cannot_draw = False two_player_game.phase = TurnPhase.SETUP player = two_player_game.get_current_player() player.deck.clear() result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert result.win_result is None assert not result.drew_card # ============================================================================= # Phase Advancement Tests # ============================================================================= class TestPhaseAdvancement: """Tests for explicit phase advancement methods.""" def test_advance_to_main(self, turn_manager: TurnManager, two_player_game: GameState): """ Test advance_to_main from DRAW phase. Verifies explicit phase control. """ two_player_game.phase = TurnPhase.DRAW turn_manager.advance_to_main(two_player_game) assert two_player_game.phase == TurnPhase.MAIN def test_advance_to_main_invalid_phase( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test advance_to_main from invalid phase raises error. Cannot go to MAIN from ATTACK. """ two_player_game.phase = TurnPhase.ATTACK with pytest.raises(PhaseTransitionError): turn_manager.advance_to_main(two_player_game) def test_advance_to_attack(self, turn_manager: TurnManager, two_player_game: GameState): """ Test advance_to_attack from MAIN phase. Standard attack declaration flow. """ two_player_game.phase = TurnPhase.MAIN turn_manager.advance_to_attack(two_player_game) assert two_player_game.phase == TurnPhase.ATTACK def test_advance_to_attack_invalid_phase( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test advance_to_attack from invalid phase raises error. Cannot attack directly from DRAW. """ two_player_game.phase = TurnPhase.DRAW with pytest.raises(PhaseTransitionError): turn_manager.advance_to_attack(two_player_game) def test_advance_to_end_from_main(self, turn_manager: TurnManager, two_player_game: GameState): """ Test advance_to_end from MAIN phase (skip attack). Player chooses not to attack. """ two_player_game.phase = TurnPhase.MAIN turn_manager.advance_to_end(two_player_game) assert two_player_game.phase == TurnPhase.END def test_advance_to_end_from_attack( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test advance_to_end from ATTACK phase. After attack resolution. """ two_player_game.phase = TurnPhase.ATTACK turn_manager.advance_to_end(two_player_game) assert two_player_game.phase == TurnPhase.END def test_skip_attack(self, turn_manager: TurnManager, two_player_game: GameState): """ Test skip_attack from MAIN phase. Convenience method for skipping attack. """ two_player_game.phase = TurnPhase.MAIN turn_manager.skip_attack(two_player_game) assert two_player_game.phase == TurnPhase.END def test_skip_attack_wrong_phase(self, turn_manager: TurnManager, two_player_game: GameState): """ Test skip_attack from wrong phase raises error. Can only skip attack from MAIN. """ two_player_game.phase = TurnPhase.ATTACK with pytest.raises(PhaseTransitionError) as exc_info: turn_manager.skip_attack(two_player_game) assert "Can only skip attack from MAIN phase" in str(exc_info.value) # ============================================================================= # Turn End Tests # ============================================================================= class TestTurnEnd: """Tests for turn end processing.""" def test_end_turn_advances_to_next_player( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that end_turn advances to the next player. Verifies turn order cycling. """ two_player_game.phase = TurnPhase.END two_player_game.turn_number = 1 turn_manager.end_turn(two_player_game, seeded_rng) assert two_player_game.current_player_id == "player2" assert two_player_game.phase == TurnPhase.DRAW def test_end_turn_from_main_phase( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that end_turn can be called from MAIN phase. Automatically advances to END first. """ two_player_game.phase = TurnPhase.MAIN result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.success assert two_player_game.current_player_id == "player2" def test_end_turn_from_attack_phase( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that end_turn can be called from ATTACK phase. Automatically advances to END first. """ two_player_game.phase = TurnPhase.ATTACK result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.success assert two_player_game.current_player_id == "player2" def test_end_turn_returns_result( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that end_turn returns a proper result object. Verifies the TurnEndResult structure. """ two_player_game.phase = TurnPhase.END result = turn_manager.end_turn(two_player_game, seeded_rng) assert isinstance(result, TurnEndResult) assert result.success # ============================================================================= # Between-Turn Effect Tests (Status Conditions) # ============================================================================= class TestBetweenTurnEffects: """Tests for between-turn status effect processing.""" def test_poison_damage_applied( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that poison damage is applied at end of turn. Default poison damage is 10. """ two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.POISONED) initial_damage = active.damage result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.success assert active.instance_id in result.between_turn_damage assert result.between_turn_damage[active.instance_id] == 10 assert active.damage == initial_damage + 10 def test_poison_damage_custom_amount( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that poison damage uses configured amount. Verifies StatusConfig.poison_damage is respected. """ two_player_game.rules.status.poison_damage = 20 two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.POISONED) result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.between_turn_damage[active.instance_id] == 20 def test_burn_damage_applied( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that burn damage is applied at end of turn. Default burn damage is 20. """ two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.BURNED) initial_damage = active.damage result = turn_manager.end_turn(two_player_game, seeded_rng) assert active.instance_id in result.between_turn_damage assert result.between_turn_damage[active.instance_id] == 20 assert active.damage == initial_damage + 20 def test_burn_damage_custom_amount( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that burn damage uses configured amount. Verifies StatusConfig.burn_damage is respected. """ two_player_game.rules.status.burn_damage = 30 two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.BURNED) result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.between_turn_damage[active.instance_id] == 30 def test_poison_and_burn_stack( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that poison and burn damage stack. Both conditions can be active simultaneously. """ two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.POISONED) active.add_status(StatusCondition.BURNED) initial_damage = active.damage result = turn_manager.end_turn(two_player_game, seeded_rng) # 10 poison + 20 burn = 30 total assert result.between_turn_damage[active.instance_id] == 30 assert active.damage == initial_damage + 30 def test_burn_recovery_flip_heads( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that burn can be removed on heads. Uses seeded RNG to get consistent heads. """ # Seed 12345 gives heads on first flip rng = SeededRandom(seed=12345) two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.BURNED) result = turn_manager.end_turn(two_player_game, rng) assert StatusCondition.BURNED not in active.status_conditions assert active.instance_id in result.status_removed assert StatusCondition.BURNED in result.status_removed[active.instance_id] def test_burn_remains_on_tails( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that burn remains on tails. Uses seeded RNG to get consistent tails. """ # Seed 42 gives tails on first flip rng = SeededRandom(seed=42) two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.BURNED) turn_manager.end_turn(two_player_game, rng) assert StatusCondition.BURNED in active.status_conditions def test_sleep_recovery_flip_heads( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that sleep can be removed on heads. Uses seeded RNG to get consistent heads. """ # Seed 12345 gives heads on first flip rng = SeededRandom(seed=12345) two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.ASLEEP) result = turn_manager.end_turn(two_player_game, rng) assert StatusCondition.ASLEEP not in active.status_conditions assert active.instance_id in result.status_removed assert StatusCondition.ASLEEP in result.status_removed[active.instance_id] def test_sleep_remains_on_tails( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that sleep remains on tails. Uses seeded RNG to get consistent tails. """ # Seed 42 gives tails on first flip rng = SeededRandom(seed=42) two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.ASLEEP) turn_manager.end_turn(two_player_game, rng) assert StatusCondition.ASLEEP in active.status_conditions def test_paralysis_removed_end_of_turn( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that paralysis is always removed at end of turn. Paralysis only lasts one turn. """ two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.PARALYZED) result = turn_manager.end_turn(two_player_game, seeded_rng) assert StatusCondition.PARALYZED not in active.status_conditions assert active.instance_id in result.status_removed assert StatusCondition.PARALYZED in result.status_removed[active.instance_id] def test_confusion_persists( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that confusion is not removed automatically at end of turn. Confusion is removed by retreat or evolution, not by time. """ two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.CONFUSED) result = turn_manager.end_turn(two_player_game, seeded_rng) # Confusion should still be active assert StatusCondition.CONFUSED in active.status_conditions # Should not be in status_removed assert active.instance_id not in result.status_removed or ( StatusCondition.CONFUSED not in result.status_removed.get(active.instance_id, []) ) def test_status_damage_causes_knockout( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, basic_pokemon_def: CardDefinition, ): """ Test that status damage can knockout a Pokemon. If damage exceeds HP, the Pokemon is knocked out. """ two_player_game.phase = TurnPhase.END active = two_player_game.get_current_player().get_active_pokemon() # Set damage to 50, HP is 60, poison will deal 10 for KO active.damage = 50 active.add_status(StatusCondition.POISONED) result = turn_manager.end_turn(two_player_game, seeded_rng) assert active.instance_id in result.knockouts # ============================================================================= # Knockout Processing Tests # ============================================================================= class TestKnockoutProcessing: """Tests for knockout processing and win condition integration.""" def test_process_knockout_moves_to_discard( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that knocked out Pokemon is moved to discard pile. Standard knockout handling. """ active = two_player_game.players["player1"].get_active_pokemon() active_id = active.instance_id turn_manager.process_knockout(two_player_game, active_id, "player2") assert len(two_player_game.players["player1"].active) == 0 assert active_id in two_player_game.players["player1"].discard def test_process_knockout_awards_points( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that opponent is awarded points for knockout. Normal Pokemon = 1 point. """ active = two_player_game.players["player1"].get_active_pokemon() active_id = active.instance_id initial_score = two_player_game.players["player2"].score turn_manager.process_knockout(two_player_game, active_id, "player2") assert two_player_game.players["player2"].score == initial_score + 1 def test_process_knockout_ex_awards_two_points( self, turn_manager: TurnManager, two_player_game: GameState, ex_pokemon_def: CardDefinition, ): """ Test that EX Pokemon knockout awards 2 points. EX, V, GX variants are worth 2 points. """ # Replace active with EX Pokemon player1 = two_player_game.players["player1"] player1.active.clear() ex_card = CardInstance(instance_id="ex-active", definition_id=ex_pokemon_def.id) player1.active.add(ex_card) two_player_game.card_registry[ex_pokemon_def.id] = ex_pokemon_def initial_score = two_player_game.players["player2"].score turn_manager.process_knockout(two_player_game, "ex-active", "player2") assert two_player_game.players["player2"].score == initial_score + 2 def test_process_knockout_triggers_win_by_points( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that knockout triggers win when points reach target. Default is 4 points to win. """ two_player_game.players["player2"].score = 3 # Need 1 more to win active = two_player_game.players["player1"].get_active_pokemon() active_id = active.instance_id result = turn_manager.process_knockout(two_player_game, active_id, "player2") assert result is not None assert result.winner_id == "player2" assert result.end_reason == GameEndReason.PRIZES_TAKEN def test_process_knockout_triggers_win_by_no_pokemon( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that knockout triggers win when player has no Pokemon. If last Pokemon is knocked out, opponent wins. """ # Remove bench Pokemon first two_player_game.players["player1"].bench.clear() active = two_player_game.players["player1"].get_active_pokemon() active_id = active.instance_id result = turn_manager.process_knockout(two_player_game, active_id, "player2") assert result is not None assert result.winner_id == "player2" assert result.end_reason == GameEndReason.NO_POKEMON def test_process_knockout_sets_forced_action( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that knockout sets forced action to select new active. When active is KO'd but bench has Pokemon, player must select new active. """ active = two_player_game.players["player1"].get_active_pokemon() active_id = active.instance_id turn_manager.process_knockout(two_player_game, active_id, "player2") current_forced = two_player_game.get_current_forced_action() assert current_forced is not None assert current_forced.player_id == "player1" assert current_forced.action_type == "select_active" def test_process_bench_knockout( self, turn_manager: TurnManager, two_player_game: GameState, ): """ Test that bench Pokemon can be knocked out. For effects like bench damage. """ bench_card = two_player_game.players["player1"].bench.cards[0] bench_id = bench_card.instance_id initial_score = two_player_game.players["player2"].score turn_manager.process_knockout(two_player_game, bench_id, "player2") assert len(two_player_game.players["player1"].bench) == 0 assert bench_id in two_player_game.players["player1"].discard assert two_player_game.players["player2"].score == initial_score + 1 # ============================================================================= # Status Knockout Integration Tests (Issues #3, #4, #6 verification) # ============================================================================= class TestStatusKnockoutIntegration: """Integration tests verifying full end_turn knockout processing. These tests verify that Issues #3, #4, and #6 from SYSTEM_REVIEW.md are fixed: - Issue #3: end_turn() processes knockouts (moves to discard, awards points) - Issue #4: Win conditions are checked AFTER knockout processing - Issue #6: The full knockout flow works end-to-end """ def test_end_turn_poison_knockout_moves_to_discard( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that poison knockout moves Pokemon to discard during end_turn. Verifies Issue #3: Pokemon knocked out by status damage should be properly moved from active zone to discard pile, not just added to the knockouts list. """ two_player_game.phase = TurnPhase.END player = two_player_game.get_current_player() active = player.get_active_pokemon() active_id = active.instance_id # Set up for lethal poison damage (60 HP, 50 damage, poison deals 10) active.damage = 50 active.add_status(StatusCondition.POISONED) result = turn_manager.end_turn(two_player_game, seeded_rng) # Verify knockout was detected assert active_id in result.knockouts # Verify Pokemon was moved to discard (Issue #3 fix) assert active_id in player.discard assert len(player.active) == 0 def test_end_turn_burn_knockout_moves_to_discard( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that burn knockout moves Pokemon to discard during end_turn. Same as poison test but with burn damage (20 instead of 10). """ two_player_game.phase = TurnPhase.END player = two_player_game.get_current_player() active = player.get_active_pokemon() active_id = active.instance_id # Set up for lethal burn damage (60 HP, 40 damage, burn deals 20) active.damage = 40 active.add_status(StatusCondition.BURNED) result = turn_manager.end_turn(two_player_game, seeded_rng) assert active_id in result.knockouts assert active_id in player.discard assert len(player.active) == 0 def test_end_turn_status_knockout_awards_points( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that status knockout awards points to the opponent. Verifies Issue #3: Opponent should receive points for status KOs, just like attack KOs. """ two_player_game.phase = TurnPhase.END player = two_player_game.get_current_player() opponent = two_player_game.players["player2"] active = player.get_active_pokemon() initial_score = opponent.score active.damage = 50 active.add_status(StatusCondition.POISONED) turn_manager.end_turn(two_player_game, seeded_rng) # Normal Pokemon is worth 1 point assert opponent.score == initial_score + 1 def test_end_turn_status_knockout_ex_awards_two_points( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ex_pokemon_def: CardDefinition, ): """ Test that status knockout of EX Pokemon awards 2 points. EX/GX Pokemon are worth 2 prize points when knocked out. """ # Replace active with EX Pokemon two_player_game.card_registry[ex_pokemon_def.id] = ex_pokemon_def player = two_player_game.get_current_player() player.active.cards.clear() ex_pokemon = CardInstance(instance_id="ex-active", definition_id=ex_pokemon_def.id) player.active.add(ex_pokemon) two_player_game.phase = TurnPhase.END opponent = two_player_game.players["player2"] initial_score = opponent.score # EX has 120 HP ex_pokemon.damage = 110 ex_pokemon.add_status(StatusCondition.POISONED) turn_manager.end_turn(two_player_game, seeded_rng) # EX Pokemon is worth 2 points assert opponent.score == initial_score + 2 def test_end_turn_status_knockout_discards_attached_energy( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, energy_def: CardDefinition, ): """ Test that attached energy is discarded when Pokemon is knocked out by status. Verifies that the full knockout processing (including attachments) happens during end_turn, not just detecting the KO. """ two_player_game.phase = TurnPhase.END player = two_player_game.get_current_player() active = player.get_active_pokemon() # Attach energy to the Pokemon energy1 = CardInstance(instance_id="energy-1", definition_id=energy_def.id) energy2 = CardInstance(instance_id="energy-2", definition_id=energy_def.id) active.attached_energy = [energy1, energy2] active.damage = 50 active.add_status(StatusCondition.POISONED) turn_manager.end_turn(two_player_game, seeded_rng) # Both energy cards should be in discard assert "energy-1" in player.discard assert "energy-2" in player.discard def test_end_turn_status_knockout_triggers_win_by_points( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ex_pokemon_def: CardDefinition, ): """ Test that status knockout can trigger win by points. Verifies Issue #4: Win condition check happens AFTER knockout processing, so the points are correctly counted. """ # Set opponent to 5 points (need 6 to win with default rules) two_player_game.card_registry[ex_pokemon_def.id] = ex_pokemon_def player = two_player_game.get_current_player() opponent = two_player_game.players["player2"] opponent.score = 5 # Replace active with EX Pokemon (worth 2 points) player.active.cards.clear() ex_pokemon = CardInstance(instance_id="ex-active", definition_id=ex_pokemon_def.id) player.active.add(ex_pokemon) two_player_game.phase = TurnPhase.END ex_pokemon.damage = 110 ex_pokemon.add_status(StatusCondition.POISONED) result = turn_manager.end_turn(two_player_game, seeded_rng) # Opponent should win (5 + 2 = 7 >= 6) assert result.win_result is not None assert result.win_result.winner_id == "player2" assert result.win_result.end_reason == GameEndReason.PRIZES_TAKEN def test_end_turn_status_knockout_triggers_win_by_no_pokemon( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that status knockout can trigger win by no Pokemon in play. Verifies Issue #4: Win condition check for "no Pokemon in play" happens AFTER the Pokemon is actually removed from play. """ two_player_game.phase = TurnPhase.END player = two_player_game.get_current_player() active = player.get_active_pokemon() # Clear bench so player has only active Pokemon player.bench.cards.clear() active.damage = 50 active.add_status(StatusCondition.POISONED) result = turn_manager.end_turn(two_player_game, seeded_rng) # Opponent should win because player has no Pokemon left assert result.win_result is not None assert result.win_result.winner_id == "player2" assert result.win_result.loser_id == "player1" assert result.win_result.end_reason == GameEndReason.NO_POKEMON def test_end_turn_status_knockout_with_bench_sets_forced_action( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test that status knockout sets forced action when player has bench Pokemon. If the knocked out Pokemon was active but player has bench Pokemon, they must select a new active Pokemon. """ two_player_game.phase = TurnPhase.END player = two_player_game.get_current_player() active = player.get_active_pokemon() # Ensure player has bench Pokemon assert len(player.bench) > 0 active.damage = 50 active.add_status(StatusCondition.POISONED) turn_manager.end_turn(two_player_game, seeded_rng) # Should have forced action to select new active current_forced = two_player_game.get_current_forced_action() assert current_forced is not None assert current_forced.player_id == player.player_id assert current_forced.action_type == "select_active" # ============================================================================= # Prize Card Mode Tests (Issue #11) # ============================================================================= class TestPrizeCardModeKnockout: """Tests for knockout processing in prize card mode. These tests verify that process_knockout handles prize card mode correctly, including random and player-choice selection. """ @pytest.fixture def prize_card_game( self, basic_pokemon_def: CardDefinition, energy_def: CardDefinition, ) -> GameState: """Create a game state with prize card mode enabled.""" from app.core.config import PrizeConfig # Create card registry card_registry = { basic_pokemon_def.id: basic_pokemon_def, energy_def.id: energy_def, } # Create player states p1_active = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id) p1_bench = CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id) p2_active = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id) p1 = PlayerState(player_id="player1") p1.active.add(p1_active) p1.bench.add(p1_bench) # Add prize cards for i in range(6): p1.prizes.add( CardInstance(instance_id=f"p1-prize-{i}", definition_id=basic_pokemon_def.id) ) p2 = PlayerState(player_id="player2") p2.active.add(p2_active) # Add prize cards for i in range(6): p2.prizes.add( CardInstance(instance_id=f"p2-prize-{i}", definition_id=basic_pokemon_def.id) ) # Create game state with prize card mode rules = RulesConfig() rules.prizes = PrizeConfig( count=6, use_prize_cards=True, prize_selection_random=True, # Random by default ) game = GameState( game_id="test-prize", rules=rules, card_registry=card_registry, players={"player1": p1, "player2": p2}, turn_order=["player1", "player2"], active_player_index=0, current_player_id="player1", turn_number=1, phase=TurnPhase.MAIN, ) return game def test_knockout_random_prize_selection( self, turn_manager: TurnManager, prize_card_game: GameState, seeded_rng: SeededRandom, ): """ Test that knockout with random prize selection auto-takes prize. In random mode, prize cards are taken automatically. """ opponent = prize_card_game.players["player2"] initial_hand = len(opponent.hand) initial_prizes = len(opponent.prizes) # Process knockout - opponent (player2) takes prize from player1's knockout turn_manager.process_knockout(prize_card_game, "p1-active", "player2", seeded_rng) # Opponent should have taken 1 prize card assert len(opponent.hand) == initial_hand + 1 assert len(opponent.prizes) == initial_prizes - 1 def test_knockout_player_choice_sets_forced_action( self, turn_manager: TurnManager, prize_card_game: GameState, seeded_rng: SeededRandom, ): """ Test that knockout with player choice sets forced action. In player choice mode, a forced action is set for prize selection. Note: When the knocked out player also needs to select a new active, select_active takes priority. This test uses a bench knockout to avoid that complication. """ # Switch to player choice mode prize_card_game.rules.prizes.prize_selection_random = False # Use bench knockout to avoid select_active conflict player = prize_card_game.players["player1"] bench_id = player.bench.cards[0].instance_id turn_manager.process_knockout(prize_card_game, bench_id, "player2", seeded_rng) # Should have forced action for prize selection current_forced = prize_card_game.get_current_forced_action() assert current_forced is not None assert current_forced.action_type == "select_prize" assert current_forced.player_id == "player2" assert current_forced.params.get("count") == 1 def test_knockout_ex_awards_two_prizes( self, turn_manager: TurnManager, prize_card_game: GameState, seeded_rng: SeededRandom, ): """ Test that knockout of EX Pokemon awards 2 prizes. EX/GX Pokemon are worth 2 prize cards. """ from app.core.models.enums import PokemonVariant # Add EX pokemon definition ex_def = CardDefinition( id="ex-pokemon", name="Pikachu EX", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, variant=PokemonVariant.EX, hp=120, pokemon_type=EnergyType.LIGHTNING, ) prize_card_game.card_registry[ex_def.id] = ex_def # Replace active with EX player = prize_card_game.players["player1"] player.active.cards.clear() ex_pokemon = CardInstance(instance_id="p1-ex", definition_id=ex_def.id) player.active.add(ex_pokemon) opponent = prize_card_game.players["player2"] initial_hand = len(opponent.hand) turn_manager.process_knockout(prize_card_game, "p1-ex", "player2", seeded_rng) # Opponent should have taken 2 prize cards assert len(opponent.hand) == initial_hand + 2 def test_knockout_win_by_empty_prizes( self, turn_manager: TurnManager, prize_card_game: GameState, seeded_rng: SeededRandom, ): """ Test that taking all prizes triggers a win. Win condition when opponent's prize pile is empty. """ opponent = prize_card_game.players["player2"] # Remove all but one prize while len(opponent.prizes) > 1: opponent.prizes.cards.pop() result = turn_manager.process_knockout(prize_card_game, "p1-active", "player2", seeded_rng) # Should win by taking all prizes assert result is not None assert result.winner_id == "player2" assert result.end_reason == GameEndReason.PRIZES_TAKEN # ============================================================================= # Turn Limit Tests # ============================================================================= class TestTurnLimit: """Tests for turn limit checking.""" def test_check_turn_limit_not_reached( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test that no win result when turn limit not reached. Game continues normally. """ two_player_game.turn_number = 15 # Default limit is 30 result = turn_manager.check_turn_limit(two_player_game) assert result is None def test_check_turn_limit_reached_higher_score_wins( self, turn_manager: TurnManager, two_player_game: GameState ): """ Test that higher score wins when turn limit reached. Standard turn limit resolution. """ two_player_game.rules.win_conditions.turn_limit = 30 two_player_game.turn_number = 31 # Over limit two_player_game.players["player1"].score = 3 two_player_game.players["player2"].score = 2 result = turn_manager.check_turn_limit(two_player_game) assert result is not None assert result.winner_id == "player1" def test_check_turn_limit_disabled(self, turn_manager: TurnManager, two_player_game: GameState): """ Test that turn limit check returns None when disabled. Some game modes may not have turn limits. """ two_player_game.rules.win_conditions.turn_limit_enabled = False two_player_game.turn_number = 100 # Way over default limit result = turn_manager.check_turn_limit(two_player_game) assert result is None # ============================================================================= # Integration Tests # ============================================================================= class TestTurnManagerIntegration: """Integration tests for complete turn flows.""" def test_complete_turn_cycle( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test a complete turn cycle: start -> main -> attack -> end. Verifies full turn flow works correctly. """ # Start turn (player 1) result = turn_manager.start_turn(two_player_game, seeded_rng) assert result.success assert two_player_game.phase == TurnPhase.MAIN assert two_player_game.current_player_id == "player1" # Advance to attack turn_manager.advance_to_attack(two_player_game) assert two_player_game.phase == TurnPhase.ATTACK # End turn result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.success assert two_player_game.current_player_id == "player2" assert two_player_game.phase == TurnPhase.DRAW def test_skip_attack_turn_cycle( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test turn cycle with skipped attack. Player can choose not to attack. """ # Start turn turn_manager.start_turn(two_player_game, seeded_rng) assert two_player_game.phase == TurnPhase.MAIN # Skip attack and end turn turn_manager.skip_attack(two_player_game) assert two_player_game.phase == TurnPhase.END result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.success assert two_player_game.current_player_id == "player2" def test_multiple_turns( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test multiple turn cycles. Verifies turn order and state transitions over multiple turns. """ # Turn 1 - Player 1 turn_manager.start_turn(two_player_game, seeded_rng) turn_manager.end_turn(two_player_game, seeded_rng) assert two_player_game.current_player_id == "player2" # Turn 1 - Player 2 turn_manager.start_turn(two_player_game, seeded_rng) turn_manager.end_turn(two_player_game, seeded_rng) assert two_player_game.current_player_id == "player1" assert two_player_game.turn_number == 2 # Turn 2 - Player 1 turn_manager.start_turn(two_player_game, seeded_rng) turn_manager.end_turn(two_player_game, seeded_rng) assert two_player_game.current_player_id == "player2" def test_turn_with_status_damage( self, turn_manager: TurnManager, two_player_game: GameState, seeded_rng: SeededRandom, ): """ Test turn cycle with status damage applied. Verifies status effects are processed at end of turn. """ # Start turn turn_manager.start_turn(two_player_game, seeded_rng) # Apply poison to active active = two_player_game.get_current_player().get_active_pokemon() active.add_status(StatusCondition.POISONED) initial_damage = active.damage # End turn - poison should deal damage result = turn_manager.end_turn(two_player_game, seeded_rng) assert result.success assert active.damage == initial_damage + 10 assert active.instance_id in result.between_turn_damage