Architectural refactor to eliminate circular imports and establish clean module boundaries: - Move enums from app/core/models/enums.py to app/core/enums.py (foundational module with zero dependencies) - Update all imports across 30 files to use new enum location - Set up clean export structure: - app.core.enums: canonical source for all enums - app.core: convenience exports for full public API - app.core.models: exports models only (not enums) - Add module exports to app/core/__init__.py and app/core/effects/__init__.py - Remove circular import workarounds from game_state.py This enables app.core.models to export GameState without circular import issues, since enums no longer depend on the models package. All 826 tests passing.
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.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.TURN_LIMIT,
|
|
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.TURN_LIMIT,
|
|
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)
|