mantimon-tcg/backend/app/core/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

381 lines
12 KiB
Python

"""Win condition checking for the Mantimon TCG game engine.
This module implements config-driven win condition checking. The game supports
multiple win conditions that can be independently enabled or disabled via
the RulesConfig.win_conditions settings.
Win Conditions (when enabled):
- all_prizes_taken: A player scores enough points (or takes all prize cards)
- no_pokemon_in_play: A player's opponent has no Pokemon in play
- cannot_draw: A player cannot draw at the start of their turn
The win condition checker is typically called:
- After resolving an attack (Pokemon knockouts may trigger win)
- At the start of a turn's draw phase (deck empty check)
- After any effect that removes Pokemon from play
Usage:
from app.core.win_conditions import check_win_conditions, WinResult
# Check if anyone has won
result = check_win_conditions(game_state)
if result is not None:
print(f"Player {result.winner_id} wins: {result.reason}")
game_state.set_winner(result.winner_id, result.end_reason)
# Check specific conditions
from app.core.win_conditions import (
check_prizes_taken,
check_no_pokemon_in_play,
check_cannot_draw,
)
prizes_result = check_prizes_taken(game_state)
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel
from app.core.models.enums import GameEndReason
if TYPE_CHECKING:
from app.core.models.game_state import GameState
class WinResult(BaseModel):
"""Result indicating a player has won the game.
Attributes:
winner_id: The player ID of the winner.
loser_id: The player ID of the loser.
end_reason: The GameEndReason enum value for why the game ended.
reason: Human-readable explanation of the win condition.
"""
winner_id: str
loser_id: str
end_reason: GameEndReason
reason: str
def check_win_conditions(game: GameState) -> WinResult | None:
"""Check all enabled win conditions and return a result if any are met.
This is the main entry point for win condition checking. It checks each
enabled condition in priority order and returns immediately if any
condition is met.
Check order:
1. Prizes/Points taken (most common win)
2. No Pokemon in play (opponent lost all Pokemon)
3. Cannot draw (deck empty at turn start)
The turn limit condition is NOT checked here - it should be checked by
the turn manager at the start of each turn.
Args:
game: The current GameState to check.
Returns:
WinResult if a win condition is met, None otherwise.
Note:
This function does NOT modify the game state. The caller is responsible
for calling game.set_winner() if a WinResult is returned.
"""
# Skip if game is already over
if game.is_game_over():
return None
win_config = game.rules.win_conditions
# Check prizes/points taken
if win_config.all_prizes_taken:
result = check_prizes_taken(game)
if result is not None:
return result
# Check no Pokemon in play
if win_config.no_pokemon_in_play:
result = check_no_pokemon_in_play(game)
if result is not None:
return result
# Check cannot draw (only relevant at draw phase)
# This is typically called at the start of draw phase, but we check
# it here for completeness. The turn manager should call this specifically.
if win_config.cannot_draw:
result = check_cannot_draw(game)
if result is not None:
return result
return None
def check_prizes_taken(game: GameState) -> WinResult | None:
"""Check if any player has scored enough points to win.
In point-based mode (default for Mantimon TCG), checks if any player's
score meets or exceeds the required point count.
In prize card mode (use_prize_cards=True), checks if any player has
taken all their prize cards (prizes zone is empty).
Args:
game: The current GameState.
Returns:
WinResult if a player has won via prizes/points, None otherwise.
"""
rules = game.rules
prize_config = rules.prizes
if prize_config.use_prize_cards:
# Prize card mode: win when all prize cards are taken
for player_id, player in game.players.items():
if player.prizes.is_empty() and game.phase.value != "setup":
# All prizes taken - this player wins
opponent_id = game.get_opponent_id(player_id)
return WinResult(
winner_id=player_id,
loser_id=opponent_id,
end_reason=GameEndReason.PRIZES_TAKEN,
reason=f"Player {player_id} took all prize cards",
)
else:
# Point-based mode: win when score reaches required count
for player_id, player in game.players.items():
if player.score >= prize_config.count:
opponent_id = game.get_opponent_id(player_id)
return WinResult(
winner_id=player_id,
loser_id=opponent_id,
end_reason=GameEndReason.PRIZES_TAKEN,
reason=f"Player {player_id} scored {player.score} points "
f"(required: {prize_config.count})",
)
return None
def check_no_pokemon_in_play(game: GameState) -> WinResult | None:
"""Check if any player has no Pokemon in play.
A player loses if they have no Pokemon in their active slot and no
Pokemon on their bench. This is checked after knockouts are resolved.
Note: During setup phase, this check is skipped as players are still
placing their initial Pokemon.
Args:
game: The current GameState.
Returns:
WinResult if a player has lost due to no Pokemon, None otherwise.
"""
# Skip during setup - players haven't placed Pokemon yet
if game.phase.value == "setup":
return None
for player_id, player in game.players.items():
if not player.has_pokemon_in_play():
# This player has no Pokemon - they lose
opponent_id = game.get_opponent_id(player_id)
return WinResult(
winner_id=opponent_id,
loser_id=player_id,
end_reason=GameEndReason.NO_POKEMON,
reason=f"Player {player_id} has no Pokemon in play",
)
return None
def check_cannot_draw(game: GameState) -> WinResult | None:
"""Check if the current player cannot draw a card.
This check is specifically for the scenario where a player must draw
at the start of their turn but their deck is empty. This should be
called at the beginning of the draw phase.
In standard rules, a player loses if they cannot draw at the start
of their turn. This does not apply to drawing during other phases
(effects that try to draw from an empty deck just draw nothing).
Args:
game: The current GameState.
Returns:
WinResult if the current player loses due to empty deck, None otherwise.
Note:
This should only be called at the start of draw phase. Outside of
draw phase, this returns None to avoid false positives.
"""
# Only check during draw phase
if game.phase.value != "draw":
return None
current_player = game.get_current_player()
if current_player.deck.is_empty():
# Current player cannot draw - they lose
opponent_id = game.get_opponent_id(game.current_player_id)
return WinResult(
winner_id=opponent_id,
loser_id=game.current_player_id,
end_reason=GameEndReason.DECK_EMPTY,
reason=f"Player {game.current_player_id} cannot draw (deck empty)",
)
return None
def check_turn_limit(game: GameState) -> WinResult | None:
"""Check if the turn limit has been reached.
When turn_limit_enabled is True, the game ends in various ways when
the turn limit is reached. This should be called at the start of each
turn to check if the limit has been exceeded.
The winner is determined by score:
- Higher score wins
- Equal scores result in a draw (winner_id will be empty string)
Args:
game: The current GameState.
Returns:
WinResult if turn limit reached, None otherwise.
Note:
A draw is represented with winner_id="" and end_reason=DRAW.
"""
win_config = game.rules.win_conditions
if not win_config.turn_limit_enabled:
return None
# Check if we've exceeded the turn limit
# turn_number counts each player's turn, so at turn_limit+1 we've exceeded
if game.turn_number <= win_config.turn_limit:
return None
# Turn limit reached - determine winner by score
player_ids = list(game.players.keys())
if len(player_ids) != 2:
# Only support 2-player for now
return None
player1_id, player2_id = player_ids
player1 = game.players[player1_id]
player2 = game.players[player2_id]
if player1.score > player2.score:
return WinResult(
winner_id=player1_id,
loser_id=player2_id,
end_reason=GameEndReason.TIMEOUT,
reason=f"Turn limit reached. {player1_id} wins with {player1.score} "
f"points vs {player2.score}",
)
elif player2.score > player1.score:
return WinResult(
winner_id=player2_id,
loser_id=player1_id,
end_reason=GameEndReason.TIMEOUT,
reason=f"Turn limit reached. {player2_id} wins with {player2.score} "
f"points vs {player1.score}",
)
else:
# Scores are equal - it's a draw
# We use DRAW end reason and empty string for winner
# The "loser" in a draw is arbitrary but we need to provide something
return WinResult(
winner_id="",
loser_id="",
end_reason=GameEndReason.DRAW,
reason=f"Turn limit reached. Game ends in a draw ({player1.score} - {player2.score})",
)
def check_resignation(game: GameState, resigning_player_id: str) -> WinResult:
"""Create a WinResult for when a player resigns.
Unlike other win conditions, resignation is triggered by a player action
rather than game state. This is a helper function to create the appropriate
WinResult.
Args:
game: The current GameState.
resigning_player_id: The ID of the player who is resigning.
Returns:
WinResult with the opponent as winner and RESIGNATION reason.
Raises:
ValueError: If resigning_player_id is not in the game.
"""
if resigning_player_id not in game.players:
raise ValueError(f"Player {resigning_player_id} not found in game")
opponent_id = game.get_opponent_id(resigning_player_id)
return WinResult(
winner_id=opponent_id,
loser_id=resigning_player_id,
end_reason=GameEndReason.RESIGNATION,
reason=f"Player {resigning_player_id} resigned",
)
def check_timeout(game: GameState, timed_out_player_id: str) -> WinResult:
"""Create a WinResult for when a player times out.
Similar to resignation, timeout is triggered externally (by a timer)
rather than by game state. This is a helper function to create the
appropriate WinResult.
Args:
game: The current GameState.
timed_out_player_id: The ID of the player who timed out.
Returns:
WinResult with the opponent as winner and TIMEOUT reason.
Raises:
ValueError: If timed_out_player_id is not in the game.
"""
if timed_out_player_id not in game.players:
raise ValueError(f"Player {timed_out_player_id} not found in game")
opponent_id = game.get_opponent_id(timed_out_player_id)
return WinResult(
winner_id=opponent_id,
loser_id=timed_out_player_id,
end_reason=GameEndReason.TIMEOUT,
reason=f"Player {timed_out_player_id} timed out",
)
def apply_win_result(game: GameState, result: WinResult) -> None:
"""Apply a WinResult to the game state.
This is a convenience function that sets the winner and end reason
on the game state. It handles the draw case where winner_id is empty.
Args:
game: The GameState to update.
result: The WinResult to apply.
"""
if result.end_reason == GameEndReason.DRAW:
# For draws, we set winner_id to None and just set the end_reason
game.winner_id = None
game.end_reason = result.end_reason
else:
game.set_winner(result.winner_id, result.end_reason)