mantimon-tcg/backend/tests/core/test_turn_manager.py
Cal Corum cbc1da3c03 Add visibility filter for client-safe game state views
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
2026-01-25 13:11:06 -06:00

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