Add turn manager with phase state machine and between-turn effects
- Implement TurnManager class for turn/phase state machine - Phase transitions: SETUP -> DRAW -> MAIN -> ATTACK -> END - Turn start: reset counters, draw card, flip energy (Pokemon Pocket style) - Turn end: apply status damage (poison/burn), check recovery (sleep/burn flip) - Between-turn paralysis auto-removal - Knockout processing with scoring and forced action setup - Integration with win condition checking (deck-out, no Pokemon, turn limit) - 60 tests covering all functionality - 644 total core tests passing at 97% coverage Completes HIGH-007 and TEST-011 from PROJECT_PLAN.json Week 4 (Game Logic) now complete - ready for Week 5 (Engine & Polish)
This commit is contained in:
parent
5e99566560
commit
eef857e972
@ -8,7 +8,7 @@
|
||||
"description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.",
|
||||
"totalEstimatedHours": 48,
|
||||
"totalTasks": 32,
|
||||
"completedTasks": 23
|
||||
"completedTasks": 25
|
||||
},
|
||||
"categories": {
|
||||
"critical": "Foundation components that block all other work",
|
||||
@ -434,15 +434,16 @@
|
||||
"description": "Implement the turn/phase state machine with valid transitions and turn start/end handling",
|
||||
"category": "high",
|
||||
"priority": 24,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["HIGH-003", "CRIT-002"],
|
||||
"files": [
|
||||
{"path": "app/core/turn_manager.py", "issue": "File does not exist"}
|
||||
{"path": "app/core/turn_manager.py", "status": "created"}
|
||||
],
|
||||
"suggestedFix": "TurnManager class with advance_phase(), end_turn(), start_turn() methods. Enforce valid transitions (DRAW->MAIN->ATTACK->END). Handle between-turn effects (poison/burn damage). Reset per-turn flags on turn start.",
|
||||
"estimatedHours": 2.5,
|
||||
"notes": "State machine should be strict about valid transitions. Consider setup phase for game initialization."
|
||||
"notes": "TurnManager implements full phase state machine, between-turn status effects (poison/burn damage, sleep/burn recovery flips, paralysis removal), turn start processing (draw, energy flip, counter resets), and knockout processing with win condition integration.",
|
||||
"completedDate": "2026-01-25"
|
||||
},
|
||||
{
|
||||
"id": "TEST-011",
|
||||
@ -450,15 +451,16 @@
|
||||
"description": "Test phase transitions, turn switching, and per-turn state resets",
|
||||
"category": "high",
|
||||
"priority": 25,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["HIGH-007", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_turn_manager.py", "issue": "File does not exist"}
|
||||
{"path": "tests/core/test_turn_manager.py", "status": "created"}
|
||||
],
|
||||
"suggestedFix": "Test: valid transitions work, invalid transitions rejected, turn switch alternates players, per-turn flags reset, between-turn effects applied",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Test poison/burn damage application between turns"
|
||||
"notes": "60 tests covering phase transitions, turn start/end, between-turn effects (poison, burn, sleep, paralysis), knockout processing, and turn limit checking. Uses SeededRandom for deterministic coin flip tests.",
|
||||
"completedDate": "2026-01-25"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-008",
|
||||
@ -638,8 +640,9 @@
|
||||
"tasks": ["HIGH-005", "TEST-009", "HIGH-006", "TEST-010", "HIGH-007", "TEST-011"],
|
||||
"estimatedHours": 14,
|
||||
"goals": ["Validation working", "Win conditions working", "Turn management working"],
|
||||
"status": "IN_PROGRESS",
|
||||
"progress": "Rules validator (HIGH-005, TEST-009) and win conditions (HIGH-006, TEST-010) complete. Added ForcedAction model, starting_hand_size config, ActiveConfig (max_active for double-battle support), and TrainerConfig.stadium_same_name_replace option. Coverage gap tests added (test_coverage_gaps.py) with 16 tests for edge cases. Fixed bug in get_opponent_id() unreachable code. 584 total core tests passing at 97% coverage. Remaining: Turn manager (HIGH-007, TEST-011)."
|
||||
"status": "COMPLETED",
|
||||
"completedDate": "2026-01-25",
|
||||
"progress": "All 6 tasks complete. Rules validator validates all 11 action types. Win conditions checker handles points/prizes, knockouts, deck-out, turn limits. Turn manager implements phase state machine, between-turn effects (poison/burn/sleep/paralysis), and knockout processing. 644 total core tests passing at 97% coverage."
|
||||
},
|
||||
"week5": {
|
||||
"theme": "Engine & Polish",
|
||||
|
||||
548
backend/app/core/turn_manager.py
Normal file
548
backend/app/core/turn_manager.py
Normal file
@ -0,0 +1,548 @@
|
||||
"""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.models.enums import GameEndReason, StatusCondition, TurnPhase
|
||||
from app.core.win_conditions import WinResult, check_cannot_draw, check_no_pokemon_in_play
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.core.models.game_state import GameState
|
||||
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. Checks for knockouts from status damage
|
||||
5. 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!")
|
||||
|
||||
# Check for win conditions after status knockouts
|
||||
# Handle knockout - move Pokemon to discard, check for game end
|
||||
# Note: The actual knockout handling (scoring, forced active selection)
|
||||
# should be handled by the game engine. We just report the knockout here.
|
||||
win_result = None
|
||||
if knockouts and rules.win_conditions.no_pokemon_in_play:
|
||||
win_result = check_no_pokemon_in_play(game)
|
||||
|
||||
# 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
|
||||
) -> WinResult | None:
|
||||
"""Process a Pokemon knockout and check for win conditions.
|
||||
|
||||
This method:
|
||||
1. Moves the knocked out Pokemon to discard
|
||||
2. Awards points to the opponent (based on variant)
|
||||
3. Checks for win by points
|
||||
4. Sets up forced action if player needs new active
|
||||
|
||||
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).
|
||||
|
||||
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 and discard
|
||||
player.active.remove(knocked_out_id)
|
||||
player.discard.add(card)
|
||||
|
||||
# Award points to opponent
|
||||
opponent = game.players[opponent_id]
|
||||
card_def = game.get_card_definition(card.definition_id)
|
||||
if card_def and card_def.variant:
|
||||
points = game.rules.prizes.points_for_knockout(card_def.variant)
|
||||
opponent.score += points
|
||||
|
||||
# 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.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:
|
||||
player.bench.remove(knocked_out_id)
|
||||
player.discard.add(card)
|
||||
|
||||
# Award points
|
||||
opponent = game.players[opponent_id]
|
||||
card_def = game.get_card_definition(card.definition_id)
|
||||
if card_def and card_def.variant:
|
||||
points = game.rules.prizes.points_for_knockout(card_def.variant)
|
||||
opponent.score += points
|
||||
|
||||
# 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 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
|
||||
1251
backend/tests/core/test_turn_manager.py
Normal file
1251
backend/tests/core/test_turn_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user