mantimon-tcg/backend/tests/core/test_win_conditions.py
Cal Corum 5e99566560 Add rules validator, win conditions checker, and coverage gap tests
- Implement rules_validator.py with config-driven action validation for all 11 action types
- Implement win_conditions.py with point/prize-based, knockout, deck-out, turn limit, and timeout checks
- Add ForcedAction model to GameState for blocking actions (e.g., select new active after KO)
- Add ActiveConfig with max_active setting for future double-battle support
- Add TrainerConfig.stadium_same_name_replace option
- Add DeckConfig.starting_hand_size option
- Rename from_energy_deck to from_energy_zone for consistency
- Fix unreachable code bug in GameState.get_opponent_id()
- Add 16 coverage gap tests for edge cases (card registry corruption, forced actions, etc.)
- 584 tests passing at 97% coverage

Completes HIGH-005, HIGH-006, TEST-009, TEST-010 from PROJECT_PLAN.json
2026-01-25 12:57:06 -06:00

1519 lines
53 KiB
Python

"""Tests for the win conditions checker module.
This module tests all win condition checking functionality:
- check_win_conditions (main entry point)
- check_prizes_taken (point/prize victory)
- check_no_pokemon_in_play (knockout victory)
- check_cannot_draw (deck out victory)
- check_turn_limit (turn limit victory/draw)
- check_resignation (resignation handling)
- check_timeout (timeout handling)
- apply_win_result (applying results to game state)
Each test includes a docstring explaining the "what" and "why" of the test.
"""
import pytest
from app.core.config import PrizeConfig, RulesConfig, WinConditionsConfig
from app.core.models.enums import GameEndReason, TurnPhase
from app.core.models.game_state import GameState, PlayerState
from app.core.win_conditions import (
WinResult,
apply_win_result,
check_cannot_draw,
check_no_pokemon_in_play,
check_prizes_taken,
check_resignation,
check_timeout,
check_turn_limit,
check_win_conditions,
)
# ============================================================================
# WinResult Model Tests
# ============================================================================
class TestWinResult:
"""Tests for the WinResult model."""
def test_win_result_creation(self):
"""Test that WinResult can be created with all required fields.
The WinResult model is the return type for all win condition checks.
It must contain winner_id, loser_id, end_reason, and reason.
"""
result = WinResult(
winner_id="player1",
loser_id="player2",
end_reason=GameEndReason.PRIZES_TAKEN,
reason="Player 1 scored 4 points",
)
assert result.winner_id == "player1"
assert result.loser_id == "player2"
assert result.end_reason == GameEndReason.PRIZES_TAKEN
assert result.reason == "Player 1 scored 4 points"
def test_win_result_serialization(self):
"""Test that WinResult serializes to JSON correctly.
WinResult may be sent to clients or logged, so JSON serialization
must work correctly.
"""
result = WinResult(
winner_id="player1",
loser_id="player2",
end_reason=GameEndReason.NO_POKEMON,
reason="Player 2 has no Pokemon in play",
)
json_data = result.model_dump()
assert json_data["winner_id"] == "player1"
assert json_data["loser_id"] == "player2"
assert json_data["end_reason"] == "no_pokemon"
assert "no Pokemon" in json_data["reason"]
# ============================================================================
# check_prizes_taken Tests
# ============================================================================
class TestCheckPrizesTaken:
"""Tests for the check_prizes_taken function."""
def test_player_wins_with_exact_points(self, extended_card_registry, card_instance_factory):
"""Test that a player wins when reaching exactly the required point count.
The default rules require 4 points to win. When a player's score
reaches exactly 4, they should win.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4 # Exactly the required amount
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(), # 4 points to win
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is not None
assert result.winner_id == "player1"
assert result.loser_id == "player2"
assert result.end_reason == GameEndReason.PRIZES_TAKEN
assert "4" in result.reason
def test_player_wins_with_more_than_required_points(
self, extended_card_registry, card_instance_factory
):
"""Test that a player wins when exceeding the required point count.
This can happen if a VMAX (3 points) knockout pushes score over the limit.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 5 # More than 4 required
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is not None
assert result.winner_id == "player1"
def test_no_winner_with_insufficient_points(
self, extended_card_registry, card_instance_factory
):
"""Test that no winner is returned when neither player has enough points.
This is the normal game state when both players have scored but
neither has reached the winning threshold.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 2
player2.score = 3
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(), # 4 points needed
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is None
def test_custom_point_threshold(self, extended_card_registry, card_instance_factory):
"""Test winning with a custom point threshold.
Free play mode allows configuring different win thresholds.
A player should win when reaching that custom threshold.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 6 # Custom threshold
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(prizes=PrizeConfig(count=6))
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is not None
assert result.winner_id == "player1"
def test_prize_card_mode_all_prizes_taken(self, extended_card_registry, card_instance_factory):
"""Test winning in prize card mode when all prizes are taken.
In classic Pokemon TCG mode (use_prize_cards=True), a player wins
when their prizes zone is empty (all prizes taken).
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Player1 has taken all prizes (empty prize zone)
player1.active.add(card_instance_factory("pikachu_base_001"))
# prizes zone is empty by default
# Player2 still has prizes
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.prizes.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(prizes=PrizeConfig(use_prize_cards=True, count=6))
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is not None
assert result.winner_id == "player1"
assert result.end_reason == GameEndReason.PRIZES_TAKEN
assert "prize" in result.reason.lower()
def test_prize_card_mode_no_winner_with_remaining_prizes(
self, extended_card_registry, card_instance_factory
):
"""Test that no winner in prize card mode when prizes remain.
Neither player should win if both still have prize cards to take.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Both players have prizes remaining
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.prizes.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.prizes.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(prizes=PrizeConfig(use_prize_cards=True, count=6))
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is None
def test_prize_card_mode_ignores_setup_phase(
self, extended_card_registry, card_instance_factory
):
"""Test that empty prizes during setup don't trigger win.
During setup, prizes haven't been dealt yet so the zone is empty.
This should not count as a win condition.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Empty prizes during setup
player1.hand.add(card_instance_factory("pikachu_base_001"))
player2.hand.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(prizes=PrizeConfig(use_prize_cards=True, count=6))
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=0,
phase=TurnPhase.SETUP,
)
result = check_prizes_taken(game)
assert result is None
def test_player2_wins(self, extended_card_registry, card_instance_factory):
"""Test that player2 can win via points.
The win condition check should work for either player, not just
the current player.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 2
player2.score = 4 # Player2 wins
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1", # It's player1's turn but player2 won
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is not None
assert result.winner_id == "player2"
assert result.loser_id == "player1"
# ============================================================================
# check_no_pokemon_in_play Tests
# ============================================================================
class TestCheckNoPokemonInPlay:
"""Tests for the check_no_pokemon_in_play function."""
def test_player_loses_with_no_pokemon(self, extended_card_registry, card_instance_factory):
"""Test that a player loses when they have no Pokemon in play.
If a player's active is knocked out and they have no bench Pokemon,
they lose the game.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Player1 has Pokemon
player1.active.add(card_instance_factory("pikachu_base_001"))
# Player2 has no Pokemon in play
# (active and bench are empty)
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_no_pokemon_in_play(game)
assert result is not None
assert result.winner_id == "player1" # Opponent wins
assert result.loser_id == "player2" # No pokemon player loses
assert result.end_reason == GameEndReason.NO_POKEMON
def test_player_survives_with_bench_pokemon(
self, extended_card_registry, card_instance_factory
):
"""Test that a player doesn't lose if they have bench Pokemon.
Even with no active Pokemon, having bench Pokemon means the game
continues (they must select a new active).
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
# Player2 has no active but has bench
player2.bench.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_no_pokemon_in_play(game)
assert result is None
def test_player_survives_with_active_pokemon(
self, extended_card_registry, card_instance_factory
):
"""Test that a player doesn't lose if they have an active Pokemon.
The normal case - player has an active Pokemon.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_no_pokemon_in_play(game)
assert result is None
def test_skipped_during_setup_phase(self, extended_card_registry, card_instance_factory):
"""Test that no-pokemon check is skipped during setup.
During setup phase, players are still placing their initial Pokemon.
Empty boards are expected and should not trigger a loss.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Both players have no Pokemon (setup phase)
player1.hand.add(card_instance_factory("pikachu_base_001"))
player2.hand.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=0,
phase=TurnPhase.SETUP,
)
result = check_no_pokemon_in_play(game)
assert result is None
def test_player1_loses_with_no_pokemon(self, extended_card_registry, card_instance_factory):
"""Test that player1 can lose via no Pokemon.
The check should work for any player, not just player2.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Player1 has no Pokemon
# Player2 has active
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_no_pokemon_in_play(game)
assert result is not None
assert result.winner_id == "player2"
assert result.loser_id == "player1"
# ============================================================================
# check_cannot_draw Tests
# ============================================================================
class TestCheckCannotDraw:
"""Tests for the check_cannot_draw function."""
def test_player_loses_with_empty_deck_at_draw_phase(
self, extended_card_registry, card_instance_factory
):
"""Test that a player loses if they can't draw at turn start.
In Pokemon TCG, a player loses if they must draw a card at the
start of their turn but their deck is empty.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
# Player1's deck is empty
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.deck.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.DRAW, # Draw phase!
)
result = check_cannot_draw(game)
assert result is not None
assert result.winner_id == "player2"
assert result.loser_id == "player1"
assert result.end_reason == GameEndReason.DECK_EMPTY
assert "cannot draw" in result.reason.lower() or "deck empty" in result.reason.lower()
def test_player_survives_with_cards_in_deck(
self, extended_card_registry, card_instance_factory
):
"""Test that a player doesn't lose if they have cards to draw.
Normal case - player has cards in deck.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.deck.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.DRAW,
)
result = check_cannot_draw(game)
assert result is None
def test_empty_deck_ignored_outside_draw_phase(
self, extended_card_registry, card_instance_factory
):
"""Test that empty deck doesn't trigger loss outside draw phase.
The deck-out loss only applies at the start of draw phase.
During main phase, having an empty deck is fine (cards might
be shuffled back in, etc.).
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
# Empty deck but not draw phase
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN, # Not draw phase
)
result = check_cannot_draw(game)
assert result is None
def test_opponent_deck_empty_not_checked(self, extended_card_registry, card_instance_factory):
"""Test that opponent's empty deck doesn't affect current player.
The deck-out check only applies to the current player who must draw.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.deck.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
# Player2's deck is empty but it's player1's turn
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.DRAW,
)
result = check_cannot_draw(game)
assert result is None
# ============================================================================
# check_turn_limit Tests
# ============================================================================
class TestCheckTurnLimit:
"""Tests for the check_turn_limit function."""
def test_higher_score_wins_at_turn_limit(self, extended_card_registry, card_instance_factory):
"""Test that the player with higher score wins at turn limit.
When the turn limit is reached, the player with more points wins.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 3
player2.score = 2
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(
win_conditions=WinConditionsConfig(turn_limit_enabled=True, turn_limit=30)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=31, # Exceeded limit
phase=TurnPhase.MAIN,
)
result = check_turn_limit(game)
assert result is not None
assert result.winner_id == "player1"
assert result.loser_id == "player2"
assert result.end_reason == GameEndReason.TIMEOUT
def test_player2_wins_with_higher_score(self, extended_card_registry, card_instance_factory):
"""Test that player2 wins if they have higher score at turn limit.
The win goes to whoever has more points, regardless of who is
the current player.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 1
player2.score = 3
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(
win_conditions=WinConditionsConfig(turn_limit_enabled=True, turn_limit=30)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=31,
phase=TurnPhase.MAIN,
)
result = check_turn_limit(game)
assert result is not None
assert result.winner_id == "player2"
assert result.loser_id == "player1"
def test_draw_on_equal_scores(self, extended_card_registry, card_instance_factory):
"""Test that equal scores result in a draw at turn limit.
If both players have the same score when turn limit is reached,
the game ends in a draw.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 2
player2.score = 2
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(
win_conditions=WinConditionsConfig(turn_limit_enabled=True, turn_limit=30)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=31,
phase=TurnPhase.MAIN,
)
result = check_turn_limit(game)
assert result is not None
assert result.winner_id == "" # Draw
assert result.loser_id == "" # Draw
assert result.end_reason == GameEndReason.DRAW
assert "draw" in result.reason.lower()
def test_no_check_when_turn_limit_disabled(self, extended_card_registry, card_instance_factory):
"""Test that turn limit is not checked when disabled.
Campaign mode or infinite games may disable the turn limit.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 1
player2.score = 2
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(win_conditions=WinConditionsConfig(turn_limit_enabled=False))
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=100, # Way past any reasonable limit
phase=TurnPhase.MAIN,
)
result = check_turn_limit(game)
assert result is None
def test_no_result_before_turn_limit(self, extended_card_registry, card_instance_factory):
"""Test that no result is returned before turn limit is reached.
The turn limit check should only trigger when the limit is exceeded.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 1
player2.score = 2
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(
win_conditions=WinConditionsConfig(turn_limit_enabled=True, turn_limit=30)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=30, # Exactly at limit, not exceeded
phase=TurnPhase.MAIN,
)
result = check_turn_limit(game)
assert result is None
def test_custom_turn_limit(self, extended_card_registry, card_instance_factory):
"""Test that custom turn limit is respected.
Different game modes may use different turn limits.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 1
player2.score = 0
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(
win_conditions=WinConditionsConfig(turn_limit_enabled=True, turn_limit=10)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=11, # Exceeded custom limit of 10
phase=TurnPhase.MAIN,
)
result = check_turn_limit(game)
assert result is not None
assert result.winner_id == "player1"
# ============================================================================
# check_resignation Tests
# ============================================================================
class TestCheckResignation:
"""Tests for the check_resignation function."""
def test_resignation_creates_correct_result(
self, extended_card_registry, card_instance_factory
):
"""Test that resignation creates the correct WinResult.
When a player resigns, their opponent wins immediately.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_resignation(game, "player1")
assert result.winner_id == "player2"
assert result.loser_id == "player1"
assert result.end_reason == GameEndReason.RESIGNATION
assert "resign" in result.reason.lower()
def test_player2_resignation(self, extended_card_registry, card_instance_factory):
"""Test that player2 can resign.
Either player should be able to resign.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_resignation(game, "player2")
assert result.winner_id == "player1"
assert result.loser_id == "player2"
def test_resignation_invalid_player_raises(self, extended_card_registry, card_instance_factory):
"""Test that resignation with invalid player ID raises error.
The resigning player must be a valid player in the game.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
with pytest.raises(ValueError, match="not found"):
check_resignation(game, "invalid_player")
# ============================================================================
# check_timeout Tests
# ============================================================================
class TestCheckTimeout:
"""Tests for the check_timeout function."""
def test_timeout_creates_correct_result(self, extended_card_registry, card_instance_factory):
"""Test that timeout creates the correct WinResult.
When a player times out, their opponent wins.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_timeout(game, "player1")
assert result.winner_id == "player2"
assert result.loser_id == "player1"
assert result.end_reason == GameEndReason.TIMEOUT
assert "timed out" in result.reason.lower()
def test_player2_timeout(self, extended_card_registry, card_instance_factory):
"""Test that player2 can time out.
Either player should be able to time out.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_timeout(game, "player2")
assert result.winner_id == "player1"
assert result.loser_id == "player2"
def test_timeout_invalid_player_raises(self, extended_card_registry, card_instance_factory):
"""Test that timeout with invalid player ID raises error.
The timed out player must be a valid player in the game.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
with pytest.raises(ValueError, match="not found"):
check_timeout(game, "invalid_player")
# ============================================================================
# check_win_conditions (Main Entry Point) Tests
# ============================================================================
class TestCheckWinConditions:
"""Tests for the check_win_conditions main entry point."""
def test_returns_none_when_no_win(self, extended_card_registry, card_instance_factory):
"""Test that None is returned when no win condition is met.
This is the normal case during gameplay.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 1
player2.score = 2
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.deck.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.deck.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_win_conditions(game)
assert result is None
def test_returns_none_when_game_already_over(
self, extended_card_registry, card_instance_factory
):
"""Test that None is returned when game is already over.
Once a game has ended, win conditions should not be re-checked.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4 # Would normally trigger win
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
winner_id="player1", # Game already over
end_reason=GameEndReason.PRIZES_TAKEN,
)
result = check_win_conditions(game)
assert result is None
def test_detects_prizes_win(self, extended_card_registry, card_instance_factory):
"""Test that prizes/points win is detected.
The main entry point should correctly delegate to check_prizes_taken.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.deck.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_win_conditions(game)
assert result is not None
assert result.end_reason == GameEndReason.PRIZES_TAKEN
def test_detects_no_pokemon_win(self, extended_card_registry, card_instance_factory):
"""Test that no-Pokemon win is detected.
The main entry point should correctly delegate to check_no_pokemon_in_play.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.deck.add(card_instance_factory("pikachu_base_001"))
# Player2 has no Pokemon
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_win_conditions(game)
assert result is not None
assert result.end_reason == GameEndReason.NO_POKEMON
assert result.winner_id == "player1"
def test_detects_cannot_draw_win(self, extended_card_registry, card_instance_factory):
"""Test that cannot-draw win is detected.
The main entry point should correctly delegate to check_cannot_draw.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
# Player1's deck is empty
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.deck.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.DRAW, # Must be draw phase
)
result = check_win_conditions(game)
assert result is not None
assert result.end_reason == GameEndReason.DECK_EMPTY
assert result.winner_id == "player2"
def test_prizes_win_takes_priority_over_no_pokemon(
self, extended_card_registry, card_instance_factory
):
"""Test that prizes win is checked before no-Pokemon.
If a player scores enough points and the opponent has no Pokemon,
the points win should be detected first (though in practice both
would mean the same winner).
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4 # Winning score
player1.active.add(card_instance_factory("pikachu_base_001"))
# Player2 has no Pokemon (also a loss condition)
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_win_conditions(game)
assert result is not None
# Prizes should be checked first
assert result.end_reason == GameEndReason.PRIZES_TAKEN
def test_respects_disabled_conditions(self, extended_card_registry, card_instance_factory):
"""Test that disabled win conditions are not checked.
Free play mode may disable certain win conditions.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4 # Would normally win
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.deck.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
player2.deck.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(
win_conditions=WinConditionsConfig(
all_prizes_taken=False, # Disabled!
no_pokemon_in_play=True,
cannot_draw=True,
)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_win_conditions(game)
# Prizes condition disabled, so no win
assert result is None
# ============================================================================
# apply_win_result Tests
# ============================================================================
class TestApplyWinResult:
"""Tests for the apply_win_result function."""
def test_applies_normal_win(self, extended_card_registry, card_instance_factory):
"""Test that a normal win result is applied correctly.
The game state should be updated with winner_id and end_reason.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = WinResult(
winner_id="player1",
loser_id="player2",
end_reason=GameEndReason.PRIZES_TAKEN,
reason="Player 1 wins",
)
apply_win_result(game, result)
assert game.winner_id == "player1"
assert game.end_reason == GameEndReason.PRIZES_TAKEN
assert game.is_game_over()
def test_applies_draw_result(self, extended_card_registry, card_instance_factory):
"""Test that a draw result is applied correctly.
For draws, winner_id should be None and end_reason should be DRAW.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = WinResult(
winner_id="",
loser_id="",
end_reason=GameEndReason.DRAW,
reason="Game ends in a draw",
)
apply_win_result(game, result)
assert game.winner_id is None
assert game.end_reason == GameEndReason.DRAW
def test_applies_different_end_reasons(self, extended_card_registry, card_instance_factory):
"""Test that different end reasons are applied correctly.
Each end reason type should be stored in the game state.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
for end_reason in [
GameEndReason.PRIZES_TAKEN,
GameEndReason.NO_POKEMON,
GameEndReason.DECK_EMPTY,
GameEndReason.RESIGNATION,
GameEndReason.TIMEOUT,
]:
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = WinResult(
winner_id="player1",
loser_id="player2",
end_reason=end_reason,
reason="Test",
)
apply_win_result(game, result)
assert game.end_reason == end_reason
# ============================================================================
# Integration / Edge Case Tests
# ============================================================================
class TestWinConditionsEdgeCases:
"""Edge case and integration tests for win conditions."""
def test_simultaneous_win_conditions(self, extended_card_registry, card_instance_factory):
"""Test behavior when multiple win conditions are met.
In Pokemon TCG, prizes/points is typically checked first. If player1
knocks out player2's last Pokemon and reaches 4 points, prizes win
should be reported (though both conditions are met).
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4 # Winning points
player1.active.add(card_instance_factory("pikachu_base_001"))
# Player2 has no Pokemon (also a loss)
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_win_conditions(game)
assert result is not None
# Prizes checked first
assert result.end_reason == GameEndReason.PRIZES_TAKEN
assert result.winner_id == "player1"
def test_zero_points_to_win(self, extended_card_registry, card_instance_factory):
"""Test edge case where 0 points are required to win.
This would mean everyone wins immediately, but the game should
handle it gracefully.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 0
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
rules = RulesConfig(prizes=PrizeConfig(count=0))
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=1,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
# Both players have >= 0 points, first one checked wins
assert result is not None
def test_all_conditions_disabled(self, extended_card_registry, card_instance_factory):
"""Test that game continues when all win conditions disabled.
While unusual, free play mode could disable all standard win
conditions (perhaps for practice/testing).
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 100 # Would normally win many times over
player1.active.add(card_instance_factory("pikachu_base_001"))
# Empty deck
# Player2 has no Pokemon
rules = RulesConfig(
win_conditions=WinConditionsConfig(
all_prizes_taken=False,
no_pokemon_in_play=False,
cannot_draw=False,
)
)
game = GameState(
game_id="test",
rules=rules,
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.DRAW,
)
result = check_win_conditions(game)
# All conditions disabled, no winner
assert result is None
def test_win_result_reason_contains_player_id(
self, extended_card_registry, card_instance_factory
):
"""Test that win result reasons include player IDs for clarity.
The reason string should indicate which player won/lost for logging.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
player1.score = 4
player1.active.add(card_instance_factory("pikachu_base_001"))
player2.active.add(card_instance_factory("pikachu_base_001"))
game = GameState(
game_id="test",
rules=RulesConfig(),
card_registry=extended_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.MAIN,
)
result = check_prizes_taken(game)
assert result is not None
assert "player1" in result.reason.lower()