- 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
381 lines
12 KiB
Python
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)
|