SECURITY: Implement hidden information filtering to prevent cheating. - Create VisibleGameState, VisiblePlayerState, VisibleZone models - get_visible_state(game, player_id): filtered view for a player - get_spectator_state(game): filtered view for spectators Hidden Information (NEVER exposed): - Opponent's hand contents (count only) - All deck contents and order - All prize card contents - Energy deck order Public Information (always visible): - Active and benched Pokemon (full details) - Discard piles (full contents) - Energy zone (available energy) - Scores, turn info, phase - Stadium in play - 44 security-critical tests verifying no information leakage - Tests check JSON serialization for hidden card ID leaks - Also adds test for configurable burn damage Completes HIGH-008 and TEST-012 from PROJECT_PLAN.json Updates security checklist: 4/5 items now verified
1272 lines
41 KiB
Python
1272 lines
41 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.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,
|
|
)
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
@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")
|
|
|
|
assert two_player_game.forced_action is not None
|
|
assert two_player_game.forced_action.player_id == "player1"
|
|
assert two_player_game.forced_action.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
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|