Architectural refactor to eliminate circular imports and establish clean module boundaries: - Move enums from app/core/models/enums.py to app/core/enums.py (foundational module with zero dependencies) - Update all imports across 30 files to use new enum location - Set up clean export structure: - app.core.enums: canonical source for all enums - app.core: convenience exports for full public API - app.core.models: exports models only (not enums) - Add module exports to app/core/__init__.py and app/core/effects/__init__.py - Remove circular import workarounds from game_state.py This enables app.core.models to export GameState without circular import issues, since enums no longer depend on the models package. All 826 tests passing.
1717 lines
56 KiB
Python
1717 lines
56 KiB
Python
"""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.enums import (
|
|
CardType,
|
|
EnergyType,
|
|
GameEndReason,
|
|
PokemonStage,
|
|
PokemonVariant,
|
|
StatusCondition,
|
|
TurnPhase,
|
|
)
|
|
from app.core.models.card import CardDefinition, CardInstance
|
|
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.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
|