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.
691 lines
26 KiB
Python
691 lines
26 KiB
Python
"""Turn and phase management for the Mantimon TCG game engine.
|
|
|
|
This module implements the turn/phase state machine, handling:
|
|
- Phase transitions within a turn (DRAW -> MAIN -> ATTACK -> END)
|
|
- Turn start/end processing
|
|
- Between-turn effects (poison/burn damage, status recovery)
|
|
- Automatic energy from energy deck (Pokemon Pocket style)
|
|
|
|
The TurnManager is stateless - it operates on a GameState and returns the
|
|
modified state. This allows for deterministic replays and testing.
|
|
|
|
Turn Structure:
|
|
1. SETUP: Initial game setup (handled separately by game initialization)
|
|
2. DRAW: Draw a card from deck (auto-advances to MAIN)
|
|
3. MAIN: Play cards, attach energy, evolve, use abilities, retreat
|
|
4. ATTACK: Declare and resolve attack (optional, can skip to END)
|
|
5. END: Apply end-of-turn effects, check knockouts, advance turn
|
|
|
|
Valid Phase Transitions:
|
|
- SETUP -> DRAW (game start)
|
|
- DRAW -> MAIN (automatic after draw)
|
|
- MAIN -> ATTACK (player choice)
|
|
- MAIN -> END (player skips attack)
|
|
- ATTACK -> END (automatic after attack resolution)
|
|
- END -> (next player's DRAW)
|
|
|
|
Usage:
|
|
from app.core.turn_manager import TurnManager
|
|
from app.core.rng import SeededRandom
|
|
|
|
manager = TurnManager()
|
|
rng = SeededRandom(seed=42)
|
|
|
|
# Start a new turn
|
|
result = manager.start_turn(game, rng)
|
|
if result.win_result:
|
|
# Player can't draw - opponent wins
|
|
game = apply_win_result(game, result.win_result)
|
|
|
|
# Advance through phases
|
|
manager.advance_to_main(game) # After draw phase actions
|
|
manager.advance_to_attack(game) # Ready to attack
|
|
manager.end_turn(game, rng) # Process end of turn
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.enums import GameEndReason, StatusCondition, TurnPhase
|
|
from app.core.win_conditions import WinResult, check_cannot_draw
|
|
|
|
if TYPE_CHECKING:
|
|
from app.core.models.game_state import GameState, PlayerState
|
|
from app.core.rng import RandomProvider
|
|
|
|
|
|
class TurnStartResult(BaseModel):
|
|
"""Result of starting a turn.
|
|
|
|
Attributes:
|
|
success: Whether the turn started successfully.
|
|
drew_card: Whether a card was drawn (False if deck empty or first turn no-draw).
|
|
energy_flipped: Whether energy was flipped from energy deck.
|
|
win_result: If set, the game ended due to deck-out.
|
|
message: Description of what happened.
|
|
"""
|
|
|
|
success: bool
|
|
drew_card: bool = False
|
|
energy_flipped: bool = False
|
|
win_result: WinResult | None = None
|
|
message: str = ""
|
|
|
|
|
|
class TurnEndResult(BaseModel):
|
|
"""Result of ending a turn.
|
|
|
|
Attributes:
|
|
success: Whether the turn ended successfully.
|
|
between_turn_damage: Dict of instance_id -> damage applied by status.
|
|
status_removed: Dict of instance_id -> list of status conditions removed.
|
|
knockouts: List of instance_ids of Pokemon knocked out by status damage.
|
|
win_result: If set, the game ended due to knockouts or other condition.
|
|
message: Description of what happened.
|
|
"""
|
|
|
|
success: bool
|
|
between_turn_damage: dict[str, int] = {}
|
|
status_removed: dict[str, list[StatusCondition]] = {}
|
|
knockouts: list[str] = []
|
|
win_result: WinResult | None = None
|
|
message: str = ""
|
|
|
|
|
|
class PhaseTransitionError(Exception):
|
|
"""Raised when an invalid phase transition is attempted."""
|
|
|
|
pass
|
|
|
|
|
|
# Valid phase transitions as a graph
|
|
VALID_TRANSITIONS: dict[TurnPhase, list[TurnPhase]] = {
|
|
TurnPhase.SETUP: [TurnPhase.DRAW],
|
|
TurnPhase.DRAW: [TurnPhase.MAIN],
|
|
TurnPhase.MAIN: [TurnPhase.ATTACK, TurnPhase.END],
|
|
TurnPhase.ATTACK: [TurnPhase.END],
|
|
TurnPhase.END: [TurnPhase.DRAW], # Next player's turn starts at DRAW
|
|
}
|
|
|
|
|
|
class TurnManager:
|
|
"""Manages turn and phase transitions for a game.
|
|
|
|
The TurnManager is stateless and operates on GameState objects.
|
|
All methods that modify state do so in-place on the provided GameState.
|
|
|
|
The manager handles:
|
|
- Phase advancement within a turn
|
|
- Turn start processing (draw, energy flip, counter resets)
|
|
- Turn end processing (status damage, recovery checks, turn advancement)
|
|
- Between-turn effects (poison, burn, sleep/paralysis recovery)
|
|
|
|
Example:
|
|
manager = TurnManager()
|
|
rng = SeededRandom(seed=42)
|
|
|
|
# Start player's turn
|
|
result = manager.start_turn(game, rng)
|
|
|
|
# Player takes actions during MAIN phase...
|
|
|
|
# End turn and process effects
|
|
result = manager.end_turn(game, rng)
|
|
"""
|
|
|
|
def can_transition(self, from_phase: TurnPhase, to_phase: TurnPhase) -> bool:
|
|
"""Check if a phase transition is valid.
|
|
|
|
Args:
|
|
from_phase: Current phase.
|
|
to_phase: Target phase.
|
|
|
|
Returns:
|
|
True if the transition is allowed.
|
|
"""
|
|
valid_targets = VALID_TRANSITIONS.get(from_phase, [])
|
|
return to_phase in valid_targets
|
|
|
|
def get_valid_transitions(self, phase: TurnPhase) -> list[TurnPhase]:
|
|
"""Get list of valid phases to transition to from the current phase.
|
|
|
|
Args:
|
|
phase: Current phase.
|
|
|
|
Returns:
|
|
List of phases that can be transitioned to.
|
|
"""
|
|
return VALID_TRANSITIONS.get(phase, [])
|
|
|
|
def _set_phase(self, game: GameState, new_phase: TurnPhase) -> None:
|
|
"""Set the game phase, validating the transition.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
new_phase: Phase to transition to.
|
|
|
|
Raises:
|
|
PhaseTransitionError: If the transition is invalid.
|
|
"""
|
|
if not self.can_transition(game.phase, new_phase):
|
|
raise PhaseTransitionError(
|
|
f"Invalid phase transition: {game.phase.value} -> {new_phase.value}. "
|
|
f"Valid transitions: {[p.value for p in VALID_TRANSITIONS.get(game.phase, [])]}"
|
|
)
|
|
game.phase = new_phase
|
|
|
|
def start_turn(self, game: GameState, rng: RandomProvider) -> TurnStartResult:
|
|
"""Start the current player's turn.
|
|
|
|
This method:
|
|
1. Resets the player's per-turn counters
|
|
2. Checks for deck-out (cannot draw) win condition
|
|
3. Draws a card (if rules allow)
|
|
4. Flips energy from energy deck (if enabled)
|
|
5. Advances phase to MAIN
|
|
|
|
Call this at the beginning of each player's turn.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
rng: RandomProvider for any random effects.
|
|
|
|
Returns:
|
|
TurnStartResult with details of turn start.
|
|
"""
|
|
player = game.get_current_player()
|
|
rules = game.rules
|
|
messages = []
|
|
|
|
# Reset per-turn state
|
|
player.reset_turn_state()
|
|
|
|
# Set phase to DRAW
|
|
game.phase = TurnPhase.DRAW
|
|
|
|
# Check deck-out before drawing (if enabled)
|
|
if rules.win_conditions.cannot_draw:
|
|
win_result = check_cannot_draw(game)
|
|
if win_result is not None:
|
|
return TurnStartResult(
|
|
success=False,
|
|
win_result=win_result,
|
|
message=f"Player {player.player_id} cannot draw - deck is empty",
|
|
)
|
|
|
|
# Determine if we should draw
|
|
is_first_turn = game.is_first_turn()
|
|
should_draw = rules.first_turn.can_draw or not is_first_turn
|
|
|
|
drew_card = False
|
|
if should_draw and len(player.deck) > 0:
|
|
card = player.deck.draw()
|
|
if card:
|
|
player.hand.add(card)
|
|
drew_card = True
|
|
messages.append("Drew a card")
|
|
elif not should_draw:
|
|
messages.append("First turn - no draw")
|
|
|
|
# Flip energy from energy deck (Pokemon Pocket style)
|
|
energy_flipped = False
|
|
if rules.energy.auto_flip_from_deck and len(player.energy_deck) > 0:
|
|
# First turn energy attach restriction check
|
|
can_attach_first_turn = rules.first_turn.can_attach_energy or not is_first_turn
|
|
if can_attach_first_turn:
|
|
energy = player.energy_deck.draw()
|
|
if energy:
|
|
player.energy_zone.add(energy)
|
|
energy_flipped = True
|
|
messages.append("Flipped energy to zone")
|
|
|
|
# Advance to MAIN phase
|
|
game.phase = TurnPhase.MAIN
|
|
|
|
return TurnStartResult(
|
|
success=True,
|
|
drew_card=drew_card,
|
|
energy_flipped=energy_flipped,
|
|
message="; ".join(messages) if messages else "Turn started",
|
|
)
|
|
|
|
def advance_to_main(self, game: GameState) -> None:
|
|
"""Advance from DRAW phase to MAIN phase.
|
|
|
|
This is typically called after start_turn() if you need explicit control,
|
|
but start_turn() already advances to MAIN automatically.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
|
|
Raises:
|
|
PhaseTransitionError: If not currently in DRAW phase.
|
|
"""
|
|
self._set_phase(game, TurnPhase.MAIN)
|
|
|
|
def advance_to_attack(self, game: GameState) -> None:
|
|
"""Advance from MAIN phase to ATTACK phase.
|
|
|
|
Call this when the player declares an attack.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
|
|
Raises:
|
|
PhaseTransitionError: If not currently in MAIN phase.
|
|
"""
|
|
self._set_phase(game, TurnPhase.ATTACK)
|
|
|
|
def advance_to_end(self, game: GameState) -> None:
|
|
"""Advance to END phase.
|
|
|
|
Can be called from MAIN (skipping attack) or ATTACK (after attack resolution).
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
|
|
Raises:
|
|
PhaseTransitionError: If transition is not valid from current phase.
|
|
"""
|
|
self._set_phase(game, TurnPhase.END)
|
|
|
|
def skip_attack(self, game: GameState) -> None:
|
|
"""Skip the attack phase and go directly to END.
|
|
|
|
This is an alias for advance_to_end() when called from MAIN phase.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
|
|
Raises:
|
|
PhaseTransitionError: If not currently in MAIN phase.
|
|
"""
|
|
if game.phase != TurnPhase.MAIN:
|
|
raise PhaseTransitionError(
|
|
f"Can only skip attack from MAIN phase, currently in {game.phase.value}"
|
|
)
|
|
self._set_phase(game, TurnPhase.END)
|
|
|
|
def end_turn(self, game: GameState, rng: RandomProvider) -> TurnEndResult:
|
|
"""End the current player's turn and apply between-turn effects.
|
|
|
|
This method:
|
|
1. Applies poison/burn damage to the current player's active Pokemon
|
|
2. Checks for status recovery (burn flip, sleep flip)
|
|
3. Removes paralysis (wears off after one turn)
|
|
4. Processes knockouts from status damage (moves Pokemon to discard, awards points)
|
|
5. Checks win conditions (after knockout processing)
|
|
6. Advances to the next player's turn
|
|
|
|
Call this when the current player's turn should end.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
rng: RandomProvider for coin flips.
|
|
|
|
Returns:
|
|
TurnEndResult with details of what happened.
|
|
"""
|
|
# Ensure we're in END phase
|
|
if game.phase != TurnPhase.END:
|
|
self.advance_to_end(game)
|
|
|
|
player = game.get_current_player()
|
|
rules = game.rules
|
|
|
|
between_turn_damage: dict[str, int] = {}
|
|
status_removed: dict[str, list[StatusCondition]] = {}
|
|
knockouts: list[str] = []
|
|
messages: list[str] = []
|
|
|
|
# Process status effects on active Pokemon
|
|
active = player.get_active_pokemon()
|
|
if active:
|
|
# Apply poison damage
|
|
if StatusCondition.POISONED in active.status_conditions:
|
|
damage = rules.status.poison_damage
|
|
active.damage += damage
|
|
between_turn_damage[active.instance_id] = (
|
|
between_turn_damage.get(active.instance_id, 0) + damage
|
|
)
|
|
messages.append(f"Poison dealt {damage} damage")
|
|
|
|
# Apply burn damage and check for recovery
|
|
if StatusCondition.BURNED in active.status_conditions:
|
|
damage = rules.status.burn_damage
|
|
active.damage += damage
|
|
between_turn_damage[active.instance_id] = (
|
|
between_turn_damage.get(active.instance_id, 0) + damage
|
|
)
|
|
messages.append(f"Burn dealt {damage} damage")
|
|
|
|
# Flip to potentially remove burn
|
|
if rules.status.burn_flip_to_remove:
|
|
if rng.coin_flip():
|
|
active.remove_status(StatusCondition.BURNED)
|
|
if active.instance_id not in status_removed:
|
|
status_removed[active.instance_id] = []
|
|
status_removed[active.instance_id].append(StatusCondition.BURNED)
|
|
messages.append("Burn removed (heads)")
|
|
else:
|
|
messages.append("Burn remains (tails)")
|
|
|
|
# Check for sleep recovery
|
|
if (
|
|
StatusCondition.ASLEEP in active.status_conditions
|
|
and rules.status.sleep_flip_to_wake
|
|
):
|
|
if rng.coin_flip():
|
|
active.remove_status(StatusCondition.ASLEEP)
|
|
if active.instance_id not in status_removed:
|
|
status_removed[active.instance_id] = []
|
|
status_removed[active.instance_id].append(StatusCondition.ASLEEP)
|
|
messages.append("Woke up from sleep (heads)")
|
|
else:
|
|
messages.append("Still asleep (tails)")
|
|
|
|
# Paralysis wears off at end of turn
|
|
if StatusCondition.PARALYZED in active.status_conditions:
|
|
active.remove_status(StatusCondition.PARALYZED)
|
|
if active.instance_id not in status_removed:
|
|
status_removed[active.instance_id] = []
|
|
status_removed[active.instance_id].append(StatusCondition.PARALYZED)
|
|
messages.append("Paralysis wore off")
|
|
|
|
# Check for knockout from status damage
|
|
card_def = game.get_card_definition(active.definition_id)
|
|
if card_def and card_def.hp and active.is_knocked_out(card_def.hp):
|
|
knockouts.append(active.instance_id)
|
|
messages.append(f"{card_def.name} knocked out by status damage!")
|
|
|
|
# Process knockouts BEFORE checking win conditions
|
|
# The opponent (who will be the next player) scores points for status KOs
|
|
win_result = None
|
|
opponent_id = game.get_opponent_id(player.player_id)
|
|
|
|
for knocked_out_id in knockouts:
|
|
ko_result = self.process_knockout(game, knocked_out_id, opponent_id, rng)
|
|
if ko_result:
|
|
win_result = ko_result
|
|
break # Game ended
|
|
|
|
# Advance to next player's turn
|
|
game.advance_turn()
|
|
|
|
return TurnEndResult(
|
|
success=True,
|
|
between_turn_damage=between_turn_damage,
|
|
status_removed=status_removed,
|
|
knockouts=knockouts,
|
|
win_result=win_result,
|
|
message="; ".join(messages) if messages else "Turn ended",
|
|
)
|
|
|
|
def process_knockout(
|
|
self,
|
|
game: GameState,
|
|
knocked_out_id: str,
|
|
opponent_id: str,
|
|
rng: RandomProvider | None = None,
|
|
) -> WinResult | None:
|
|
"""Process a Pokemon knockout and check for win conditions.
|
|
|
|
This method:
|
|
1. Moves the knocked out Pokemon to discard
|
|
2. Awards points/prizes to the opponent (based on variant and game mode)
|
|
3. Checks for win by points or all prizes taken
|
|
4. Sets up forced action if player needs new active or to select prizes
|
|
|
|
Note: This should be called by the game engine after damage resolution.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
knocked_out_id: Instance ID of the knocked out Pokemon.
|
|
opponent_id: Player ID of the opponent (who scores points/takes prizes).
|
|
rng: RandomProvider for random prize selection (required if using
|
|
prize card mode with random selection).
|
|
|
|
Returns:
|
|
WinResult if the knockout ends the game, None otherwise.
|
|
"""
|
|
# Find the knockout Pokemon and its owner
|
|
for player_id, player in game.players.items():
|
|
card = player.active.get(knocked_out_id)
|
|
if card:
|
|
# Found in active - remove from zone
|
|
player.active.remove(knocked_out_id)
|
|
|
|
# TODO: Future hook point - pre_knockout_discard event
|
|
# This would allow effects to redirect energy/tools elsewhere
|
|
|
|
# Discard all attached energy
|
|
for energy in card.attached_energy:
|
|
player.discard.add(energy)
|
|
card.attached_energy = []
|
|
|
|
# Discard all attached tools
|
|
for tool in card.attached_tools:
|
|
player.discard.add(tool)
|
|
card.attached_tools = []
|
|
|
|
# Discard entire evolution stack (cards underneath)
|
|
for underneath in card.cards_underneath:
|
|
player.discard.add(underneath)
|
|
card.cards_underneath = []
|
|
|
|
# Finally discard the Pokemon itself
|
|
player.discard.add(card)
|
|
|
|
# Award points/prizes to opponent based on game mode
|
|
opponent = game.players[opponent_id]
|
|
card_def = game.get_card_definition(card.definition_id)
|
|
prizes_to_award = 1 # Default for normal Pokemon
|
|
if card_def and card_def.variant:
|
|
prizes_to_award = game.rules.prizes.points_for_knockout(card_def.variant)
|
|
|
|
if game.rules.prizes.use_prize_cards:
|
|
# Prize card mode - opponent takes prize cards
|
|
win_result = self._award_prize_cards(
|
|
game, opponent, opponent_id, prizes_to_award, rng
|
|
)
|
|
if win_result:
|
|
return win_result
|
|
else:
|
|
# Point-based mode - add to score
|
|
opponent.score += prizes_to_award
|
|
|
|
# Check for win by points
|
|
if opponent.score >= game.rules.prizes.count:
|
|
loser_id = game.get_opponent_id(opponent_id)
|
|
return WinResult(
|
|
winner_id=opponent_id,
|
|
loser_id=loser_id,
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
reason=f"Player {opponent_id} scored {opponent.score} points",
|
|
)
|
|
|
|
# Check if owner has no Pokemon left
|
|
if not player.has_pokemon_in_play():
|
|
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",
|
|
)
|
|
|
|
# Set up forced action to select new active
|
|
if player.has_benched_pokemon() and not player.has_active_pokemon():
|
|
from app.core.models.game_state import ForcedAction
|
|
|
|
game.add_forced_action(
|
|
ForcedAction(
|
|
player_id=player_id,
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out. Select a new active Pokemon.",
|
|
)
|
|
)
|
|
|
|
return None
|
|
|
|
# Check bench too (for bench damage knockouts)
|
|
card = player.bench.get(knocked_out_id)
|
|
if card:
|
|
# Remove from bench
|
|
player.bench.remove(knocked_out_id)
|
|
|
|
# TODO: Future hook point - pre_knockout_discard event
|
|
|
|
# Discard all attached energy
|
|
for energy in card.attached_energy:
|
|
player.discard.add(energy)
|
|
card.attached_energy = []
|
|
|
|
# Discard all attached tools
|
|
for tool in card.attached_tools:
|
|
player.discard.add(tool)
|
|
card.attached_tools = []
|
|
|
|
# Discard entire evolution stack
|
|
for underneath in card.cards_underneath:
|
|
player.discard.add(underneath)
|
|
card.cards_underneath = []
|
|
|
|
# Discard the Pokemon
|
|
player.discard.add(card)
|
|
|
|
# Award points/prizes to opponent based on game mode
|
|
opponent = game.players[opponent_id]
|
|
card_def = game.get_card_definition(card.definition_id)
|
|
prizes_to_award = 1 # Default for normal Pokemon
|
|
if card_def and card_def.variant:
|
|
prizes_to_award = game.rules.prizes.points_for_knockout(card_def.variant)
|
|
|
|
if game.rules.prizes.use_prize_cards:
|
|
# Prize card mode - opponent takes prize cards
|
|
win_result = self._award_prize_cards(
|
|
game, opponent, opponent_id, prizes_to_award, rng
|
|
)
|
|
if win_result:
|
|
return win_result
|
|
else:
|
|
# Point-based mode - add to score
|
|
opponent.score += prizes_to_award
|
|
|
|
# Check for win by points
|
|
if opponent.score >= game.rules.prizes.count:
|
|
loser_id = game.get_opponent_id(opponent_id)
|
|
return WinResult(
|
|
winner_id=opponent_id,
|
|
loser_id=loser_id,
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
reason=f"Player {opponent_id} scored {opponent.score} points",
|
|
)
|
|
|
|
# Check if owner has no Pokemon left
|
|
if not player.has_pokemon_in_play():
|
|
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
|
|
|
|
return None
|
|
|
|
def _award_prize_cards(
|
|
self,
|
|
game: GameState,
|
|
opponent: PlayerState,
|
|
opponent_id: str,
|
|
count: int,
|
|
rng: RandomProvider | None,
|
|
) -> WinResult | None:
|
|
"""Award prize cards to the opponent after a knockout.
|
|
|
|
Handles both random and player-choice prize selection modes.
|
|
|
|
Args:
|
|
game: GameState to modify.
|
|
opponent: PlayerState of the player receiving prizes.
|
|
opponent_id: Player ID of the opponent.
|
|
count: Number of prize cards to award.
|
|
rng: RandomProvider for random selection (required if random mode).
|
|
|
|
Returns:
|
|
WinResult if taking prizes ends the game, None otherwise.
|
|
"""
|
|
from app.core.models.game_state import ForcedAction
|
|
|
|
# Cap prizes to take at available prizes
|
|
prizes_to_take = min(count, len(opponent.prizes))
|
|
|
|
if prizes_to_take == 0:
|
|
return None
|
|
|
|
if game.rules.prizes.prize_selection_random:
|
|
# Random selection - take cards automatically
|
|
if rng is None:
|
|
# Fallback: take from the front if no RNG provided
|
|
for _ in range(prizes_to_take):
|
|
if opponent.prizes.cards:
|
|
prize_card = opponent.prizes.cards.pop(0)
|
|
opponent.hand.add(prize_card)
|
|
else:
|
|
for _ in range(prizes_to_take):
|
|
if opponent.prizes.cards:
|
|
idx = rng.randint(0, len(opponent.prizes.cards) - 1)
|
|
prize_card = opponent.prizes.cards.pop(idx)
|
|
opponent.hand.add(prize_card)
|
|
else:
|
|
# Player chooses - add to forced action queue
|
|
# Multiple forced actions can be queued (e.g., double knockout)
|
|
game.add_forced_action(
|
|
ForcedAction(
|
|
player_id=opponent_id,
|
|
action_type="select_prize",
|
|
reason=f"Select {prizes_to_take} prize card(s)",
|
|
params={"count": prizes_to_take},
|
|
)
|
|
)
|
|
|
|
# Check for win by all prizes taken
|
|
if len(opponent.prizes) == 0:
|
|
loser_id = game.get_opponent_id(opponent_id)
|
|
return WinResult(
|
|
winner_id=opponent_id,
|
|
loser_id=loser_id,
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
reason=f"Player {opponent_id} took all prize cards",
|
|
)
|
|
|
|
return None
|
|
|
|
def check_turn_limit(self, game: GameState) -> WinResult | None:
|
|
"""Check if the turn limit has been reached.
|
|
|
|
Should be called at the start of each turn (before start_turn).
|
|
If the turn limit is reached, returns a WinResult based on score.
|
|
|
|
Args:
|
|
game: GameState to check.
|
|
|
|
Returns:
|
|
WinResult if turn limit reached, None otherwise.
|
|
"""
|
|
rules = game.rules
|
|
if not rules.win_conditions.turn_limit_enabled:
|
|
return None
|
|
|
|
if game.turn_number > rules.win_conditions.turn_limit:
|
|
# Turn limit reached - determine winner by score
|
|
from app.core.win_conditions import check_turn_limit
|
|
|
|
return check_turn_limit(game)
|
|
|
|
return None
|