Major refactor to properly track attached cards and evolution history: Model Changes (app/core/models/card.py): - Change attached_energy from list[str] to list[CardInstance] - Change attached_tools from list[str] to list[CardInstance] - Add cards_underneath field for evolution stack tracking - Update attach_energy/detach_energy to work with CardInstance - Add attach_tool/detach_tool methods - Add get_all_attached_cards helper Engine Changes (app/core/engine.py): - _execute_attach_energy: Pass full CardInstance to attach_energy - _execute_evolve: Build evolution stack, transfer attachments, clear status - _execute_retreat: Detached energy goes to discard pile - Fix: Evolution now clears status conditions (Pokemon TCG standard) Game State (app/core/models/game_state.py): - find_card_instance now searches attached_energy, attached_tools, cards_underneath Turn Manager (app/core/turn_manager.py): - process_knockout: Discard all attached energy, tools, and evolution stack Effects (app/core/effects/handlers.py): - discard_energy: Find owner's discard pile and move detached energy there - NEW devolve effect: Remove evolution stages with configurable destination - Fix: Use EffectType.SPECIAL instead of non-existent EffectType.ZONE Rules Validator (app/core/rules_validator.py): - Update energy type checking to iterate CardInstance objects Tests: - Update existing tests for new CardInstance-based energy attachment - NEW test_evolution_stack.py with 28 comprehensive tests covering: - Evolution stack building (Basic -> Stage 1 -> Stage 2) - Energy/tool transfer and damage carryover on evolution - Devolve effect (single/multi stage, hand/discard destination, KO check) - Knockout processing with all attachments going to discard - find_card_instance for attached cards and evolution stack All 765 tests pass.
962 lines
32 KiB
Python
962 lines
32 KiB
Python
"""Action validation for the Mantimon TCG game engine.
|
|
|
|
This module validates all player actions against game state and rules configuration.
|
|
It is security-critical - all actions must pass validation before execution.
|
|
|
|
The validation system checks:
|
|
1. Universal conditions (game over, player turn, phase)
|
|
2. Forced action requirements
|
|
3. Action-specific rules and limits
|
|
|
|
Usage:
|
|
result = validate_action(game, player_id, action)
|
|
if not result.valid:
|
|
return error_response(result.reason)
|
|
# Proceed with action execution
|
|
|
|
Example:
|
|
>>> game = GameState(...)
|
|
>>> action = AttackAction(attack_index=0)
|
|
>>> result = validate_action(game, "player1", action)
|
|
>>> if result.valid:
|
|
... execute_attack(game, "player1", action)
|
|
"""
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.models.actions import (
|
|
VALID_PHASES_FOR_ACTION,
|
|
Action,
|
|
AttachEnergyAction,
|
|
AttackAction,
|
|
EvolvePokemonAction,
|
|
PassAction,
|
|
PlayPokemonAction,
|
|
PlayTrainerAction,
|
|
ResignAction,
|
|
RetreatAction,
|
|
SelectActiveAction,
|
|
SelectPrizeAction,
|
|
UseAbilityAction,
|
|
)
|
|
from app.core.models.card import CardDefinition, CardInstance
|
|
from app.core.models.enums import (
|
|
EnergyType,
|
|
StatusCondition,
|
|
TrainerType,
|
|
TurnPhase,
|
|
)
|
|
from app.core.models.game_state import GameState, PlayerState, Zone
|
|
|
|
|
|
class ValidationResult(BaseModel):
|
|
"""Result of validating a player action.
|
|
|
|
Attributes:
|
|
valid: Whether the action is allowed.
|
|
reason: Explanation if the action is invalid (None if valid).
|
|
"""
|
|
|
|
valid: bool
|
|
reason: str | None = None
|
|
|
|
|
|
def validate_action(game: GameState, player_id: str, action: Action) -> ValidationResult:
|
|
"""Validate whether a player action is legal.
|
|
|
|
This is the main entry point for action validation. It performs:
|
|
1. Universal checks (game over, turn ownership, phase validity)
|
|
2. Forced action enforcement
|
|
3. Action-specific validation
|
|
|
|
Args:
|
|
game: Current game state.
|
|
player_id: ID of the player attempting the action.
|
|
action: The action to validate.
|
|
|
|
Returns:
|
|
ValidationResult with valid=True if action is allowed,
|
|
or valid=False with a reason explaining why not.
|
|
"""
|
|
# 1. Check if game is over
|
|
if game.is_game_over():
|
|
return ValidationResult(valid=False, reason="Game is over")
|
|
|
|
# 2. Check for forced action
|
|
if game.forced_action is not None:
|
|
return _check_forced_action(game, player_id, action)
|
|
|
|
# 3. Check if it's the player's turn (resign always allowed)
|
|
if action.type != "resign" and not game.is_player_turn(player_id):
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Not your turn (current player: {game.current_player_id})",
|
|
)
|
|
|
|
# 4. Check if action is valid for current phase
|
|
valid_phases = VALID_PHASES_FOR_ACTION.get(action.type, [])
|
|
if game.phase.value not in valid_phases:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot perform {action.type} during {game.phase.value} phase "
|
|
f"(valid phases: {', '.join(valid_phases)})",
|
|
)
|
|
|
|
# 5. Get player state
|
|
player = game.players.get(player_id)
|
|
if player is None:
|
|
return ValidationResult(valid=False, reason=f"Player {player_id} not found")
|
|
|
|
# 6. Dispatch to action-specific validator
|
|
validators = {
|
|
"play_pokemon": _validate_play_pokemon,
|
|
"evolve": _validate_evolve_pokemon,
|
|
"attach_energy": _validate_attach_energy,
|
|
"play_trainer": _validate_play_trainer,
|
|
"use_ability": _validate_use_ability,
|
|
"attack": _validate_attack,
|
|
"retreat": _validate_retreat,
|
|
"pass": _validate_pass,
|
|
"select_prize": _validate_select_prize,
|
|
"select_active": _validate_select_active,
|
|
"resign": _validate_resign,
|
|
}
|
|
|
|
validator = validators.get(action.type)
|
|
if validator is None:
|
|
return ValidationResult(valid=False, reason=f"Unknown action type: {action.type}")
|
|
|
|
return validator(game, player, action)
|
|
|
|
|
|
# =============================================================================
|
|
# Universal Validation Helpers
|
|
# =============================================================================
|
|
|
|
|
|
def _check_forced_action(game: GameState, player_id: str, action: Action) -> ValidationResult:
|
|
"""Check if the action satisfies a forced action requirement.
|
|
|
|
When a forced action is pending, only the specified player can act,
|
|
and only with the specified action type.
|
|
"""
|
|
forced = game.forced_action
|
|
|
|
if forced.player_id != player_id:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Waiting for {forced.player_id} to complete required action: {forced.reason}",
|
|
)
|
|
|
|
if action.type != forced.action_type:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Must complete {forced.action_type} action: {forced.reason}",
|
|
)
|
|
|
|
# The action matches the forced action requirement - now validate it normally
|
|
player = game.players.get(player_id)
|
|
if player is None:
|
|
return ValidationResult(valid=False, reason=f"Player {player_id} not found")
|
|
|
|
# For forced actions, we skip phase validation since they can happen at special times
|
|
validators = {
|
|
"select_active": _validate_select_active,
|
|
"select_prize": _validate_select_prize,
|
|
}
|
|
|
|
validator = validators.get(action.type)
|
|
if validator is None:
|
|
return ValidationResult(valid=False, reason=f"Invalid forced action type: {action.type}")
|
|
|
|
return validator(game, player, action)
|
|
|
|
|
|
def _check_first_turn_restriction(
|
|
game: GameState, restriction_name: str, action_description: str
|
|
) -> ValidationResult | None:
|
|
"""Check if a first-turn restriction applies.
|
|
|
|
Args:
|
|
game: Current game state.
|
|
restriction_name: Name of the restriction flag in FirstTurnConfig.
|
|
action_description: Human-readable description for error message.
|
|
|
|
Returns:
|
|
ValidationResult if restricted, None if allowed.
|
|
"""
|
|
if not game.is_first_turn():
|
|
return None # Not first turn, no restriction
|
|
|
|
# Check the specific restriction
|
|
first_turn_config = game.rules.first_turn
|
|
can_perform = getattr(first_turn_config, restriction_name, True)
|
|
|
|
if not can_perform:
|
|
return ValidationResult(valid=False, reason=f"Cannot {action_description} on first turn")
|
|
|
|
return None # Allowed
|
|
|
|
|
|
def _get_card_from_zone(zone: Zone, card_id: str) -> CardInstance | None:
|
|
"""Get a card from a zone by instance ID."""
|
|
return zone.get(card_id)
|
|
|
|
|
|
def _get_card_definition(game: GameState, definition_id: str) -> CardDefinition | None:
|
|
"""Get a card definition from the registry."""
|
|
return game.card_registry.get(definition_id)
|
|
|
|
|
|
def _get_pokemon_in_play(player: PlayerState, pokemon_id: str) -> CardInstance | None:
|
|
"""Get a Pokemon that is in play (active or bench)."""
|
|
# Check active
|
|
card = player.active.get(pokemon_id)
|
|
if card:
|
|
return card
|
|
|
|
# Check bench
|
|
return player.bench.get(pokemon_id)
|
|
|
|
|
|
def _is_paralyzed_or_asleep(pokemon: CardInstance) -> StatusCondition | None:
|
|
"""Check if Pokemon has Paralyzed or Asleep status.
|
|
|
|
Returns the blocking status condition, or None if neither applies.
|
|
"""
|
|
if pokemon.has_status(StatusCondition.PARALYZED):
|
|
return StatusCondition.PARALYZED
|
|
if pokemon.has_status(StatusCondition.ASLEEP):
|
|
return StatusCondition.ASLEEP
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Energy Cost Validation
|
|
# =============================================================================
|
|
|
|
|
|
def _get_attached_energy_types(game: GameState, pokemon: CardInstance) -> list[EnergyType]:
|
|
"""Get list of energy types provided by attached energy cards.
|
|
|
|
Special energy can provide multiple types, so this returns a flat list
|
|
of all energy types available from attached cards.
|
|
|
|
Args:
|
|
game: Current game state (for card registry lookup).
|
|
pokemon: The Pokemon with attached energy.
|
|
|
|
Returns:
|
|
List of EnergyType values provided by attached energy.
|
|
"""
|
|
energy_types: list[EnergyType] = []
|
|
|
|
# Energy cards are now CardInstance objects stored directly on the Pokemon
|
|
for energy_card in pokemon.attached_energy:
|
|
# Get the definition
|
|
definition = game.card_registry.get(energy_card.definition_id)
|
|
if definition is None:
|
|
continue
|
|
|
|
# Add all energy types this card provides
|
|
if definition.energy_provides:
|
|
energy_types.extend(definition.energy_provides)
|
|
elif definition.energy_type:
|
|
# Fallback: basic energy provides its type
|
|
energy_types.append(definition.energy_type)
|
|
|
|
return energy_types
|
|
|
|
|
|
def _can_pay_energy_cost(
|
|
game: GameState, pokemon: CardInstance, cost: list[EnergyType]
|
|
) -> tuple[bool, str | None]:
|
|
"""Check if attached energy can satisfy an attack/retreat cost.
|
|
|
|
Algorithm:
|
|
1. Get list of energy types provided by all attached energy cards
|
|
2. Create working copy of available energy
|
|
3. For each SPECIFIC (non-colorless) energy in cost:
|
|
- Find and remove matching energy from available
|
|
- If no match, return failure
|
|
4. For each COLORLESS energy in cost:
|
|
- Find and remove any remaining energy
|
|
- If none left, return failure
|
|
5. Return success
|
|
|
|
Args:
|
|
game: Current game state.
|
|
pokemon: The Pokemon attempting to pay the cost.
|
|
cost: List of energy types required.
|
|
|
|
Returns:
|
|
Tuple of (can_pay: bool, error_message: str | None).
|
|
"""
|
|
available = _get_attached_energy_types(game, pokemon)
|
|
|
|
# Separate specific and colorless costs
|
|
specific_costs = [e for e in cost if e != EnergyType.COLORLESS]
|
|
colorless_count = len([e for e in cost if e == EnergyType.COLORLESS])
|
|
|
|
# Try to pay specific costs first
|
|
for energy_type in specific_costs:
|
|
if energy_type in available:
|
|
available.remove(energy_type)
|
|
else:
|
|
return (
|
|
False,
|
|
f"Insufficient {energy_type.value} energy "
|
|
f"(need {specific_costs.count(energy_type)}, "
|
|
f"have {_get_attached_energy_types(game, pokemon).count(energy_type)})",
|
|
)
|
|
|
|
# Pay colorless costs with any remaining energy
|
|
if colorless_count > len(available):
|
|
total_needed = len(cost)
|
|
total_have = len(_get_attached_energy_types(game, pokemon))
|
|
return (
|
|
False,
|
|
f"Insufficient energy (need {total_needed}, have {total_have})",
|
|
)
|
|
|
|
return (True, None)
|
|
|
|
|
|
# =============================================================================
|
|
# Action-Specific Validators
|
|
# =============================================================================
|
|
|
|
|
|
def _validate_play_pokemon(
|
|
game: GameState, player: PlayerState, action: PlayPokemonAction
|
|
) -> ValidationResult:
|
|
"""Validate playing a Basic Pokemon from hand.
|
|
|
|
Checks:
|
|
- Card is in player's hand
|
|
- Card is a Basic Pokemon
|
|
- There's space to play it (bench not full, or active empty in setup)
|
|
"""
|
|
# Check card is in hand
|
|
card = _get_card_from_zone(player.hand, action.card_instance_id)
|
|
if card is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card {action.card_instance_id} not found in hand",
|
|
)
|
|
|
|
# Get card definition
|
|
definition = _get_card_definition(game, card.definition_id)
|
|
if definition is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {card.definition_id} not found",
|
|
)
|
|
|
|
# Check it's a Pokemon
|
|
if not definition.is_pokemon():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{definition.name}' is not a Pokemon card",
|
|
)
|
|
|
|
# Check it's a Basic Pokemon
|
|
if not definition.is_basic_pokemon():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{definition.name}' is a {definition.stage.value} Pokemon, not Basic. "
|
|
"Only Basic Pokemon can be played directly.",
|
|
)
|
|
|
|
# Check placement is valid
|
|
if game.phase == TurnPhase.SETUP:
|
|
# During setup, can play to active (if space) or bench (if space)
|
|
if action.to_active:
|
|
max_active = game.rules.active.max_active
|
|
current_active = len(player.active)
|
|
if current_active >= max_active:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Active slot(s) full ({current_active}/{max_active})",
|
|
)
|
|
else:
|
|
if not player.can_bench_pokemon(game.rules):
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Bench is full ({game.rules.bench.max_size}/{game.rules.bench.max_size})",
|
|
)
|
|
else:
|
|
# During main phase, can only play to bench
|
|
if not player.can_bench_pokemon(game.rules):
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Bench is full ({game.rules.bench.max_size}/{game.rules.bench.max_size})",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_evolve_pokemon(
|
|
game: GameState, player: PlayerState, action: EvolvePokemonAction
|
|
) -> ValidationResult:
|
|
"""Validate evolving a Pokemon.
|
|
|
|
Checks:
|
|
- Evolution card is in player's hand
|
|
- Target Pokemon is in play
|
|
- Evolution chain is correct (evolves_from matches target's name)
|
|
- Evolution timing rules (not same turn as played, not first turn, etc.)
|
|
"""
|
|
# Check evolution card is in hand
|
|
evo_card = _get_card_from_zone(player.hand, action.evolution_card_id)
|
|
if evo_card is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Evolution card {action.evolution_card_id} not found in hand",
|
|
)
|
|
|
|
# Get evolution card definition
|
|
evo_def = _get_card_definition(game, evo_card.definition_id)
|
|
if evo_def is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {evo_card.definition_id} not found",
|
|
)
|
|
|
|
# Check it's a Pokemon
|
|
if not evo_def.is_pokemon():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{evo_def.name}' is not a Pokemon card",
|
|
)
|
|
|
|
# Check it's an evolution (not Basic)
|
|
if evo_def.is_basic_pokemon() and not evo_def.requires_evolution_from_variant():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{evo_def.name}' is a Basic Pokemon, not an evolution",
|
|
)
|
|
|
|
# Check target Pokemon is in play
|
|
target = _get_pokemon_in_play(player, action.target_pokemon_id)
|
|
if target is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Target Pokemon {action.target_pokemon_id} not found in play",
|
|
)
|
|
|
|
# Get target definition
|
|
target_def = _get_card_definition(game, target.definition_id)
|
|
if target_def is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Target card definition {target.definition_id} not found",
|
|
)
|
|
|
|
# Check evolution chain matches
|
|
if evo_def.evolves_from != target_def.name:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{evo_def.name}' evolves from '{evo_def.evolves_from}', "
|
|
f"not from '{target_def.name}'",
|
|
)
|
|
|
|
# Check evolution timing - not same turn as played
|
|
if target.turn_played == game.turn_number and not game.rules.evolution.same_turn_as_played:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot evolve '{target_def.name}': Pokemon was played this turn "
|
|
f"(turn {game.turn_number})",
|
|
)
|
|
|
|
# Check evolution timing - not same turn as previous evolution
|
|
if target.turn_evolved == game.turn_number and not game.rules.evolution.same_turn_as_evolution:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot evolve '{target_def.name}': Pokemon already evolved this turn",
|
|
)
|
|
|
|
# Check first turn restriction
|
|
if game.is_first_turn() and not game.rules.evolution.first_turn_of_game:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="Cannot evolve Pokemon on the first turn of the game",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_attach_energy(
|
|
game: GameState, player: PlayerState, action: AttachEnergyAction
|
|
) -> ValidationResult:
|
|
"""Validate attaching an energy card.
|
|
|
|
Checks:
|
|
- Energy card is in hand (or energy zone if from_energy_zone=True)
|
|
- Card is an Energy card
|
|
- Target Pokemon is in play and belongs to player
|
|
- Energy attachment limit not exceeded
|
|
- First turn restrictions
|
|
"""
|
|
# Determine source zone
|
|
if action.from_energy_zone:
|
|
source_zone = player.energy_zone
|
|
source_name = "energy zone"
|
|
else:
|
|
source_zone = player.hand
|
|
source_name = "hand"
|
|
|
|
# Check card is in source zone
|
|
card = _get_card_from_zone(source_zone, action.energy_card_id)
|
|
if card is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Energy card {action.energy_card_id} not found in {source_name}",
|
|
)
|
|
|
|
# Get card definition
|
|
definition = _get_card_definition(game, card.definition_id)
|
|
if definition is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {card.definition_id} not found",
|
|
)
|
|
|
|
# Check it's an Energy card
|
|
if not definition.is_energy():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{definition.name}' is not an Energy card",
|
|
)
|
|
|
|
# Check target Pokemon is in play
|
|
target = _get_pokemon_in_play(player, action.target_pokemon_id)
|
|
if target is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Target Pokemon {action.target_pokemon_id} not found in play",
|
|
)
|
|
|
|
# Check energy attachment limit
|
|
if not player.can_attach_energy(game.rules):
|
|
limit = game.rules.energy.attachments_per_turn
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Energy attachment limit reached "
|
|
f"({player.energy_attachments_this_turn}/{limit} this turn)",
|
|
)
|
|
|
|
# Check first turn restriction
|
|
restriction = _check_first_turn_restriction(game, "can_attach_energy", "attach energy")
|
|
if restriction is not None:
|
|
return restriction
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_play_trainer(
|
|
game: GameState, player: PlayerState, action: PlayTrainerAction
|
|
) -> ValidationResult:
|
|
"""Validate playing a Trainer card.
|
|
|
|
Checks:
|
|
- Card is in player's hand
|
|
- Card is a Trainer card
|
|
- Per-subtype limits (Supporter, Stadium, Item, Tool)
|
|
- First turn Supporter restriction
|
|
- Stadium replacement rules
|
|
- Tool attachment validation
|
|
"""
|
|
# Check card is in hand
|
|
card = _get_card_from_zone(player.hand, action.card_instance_id)
|
|
if card is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card {action.card_instance_id} not found in hand",
|
|
)
|
|
|
|
# Get card definition
|
|
definition = _get_card_definition(game, card.definition_id)
|
|
if definition is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {card.definition_id} not found",
|
|
)
|
|
|
|
# Check it's a Trainer card
|
|
if not definition.is_trainer():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"'{definition.name}' is not a Trainer card",
|
|
)
|
|
|
|
# Validate based on trainer subtype
|
|
trainer_type = definition.trainer_type
|
|
|
|
if trainer_type == TrainerType.SUPPORTER:
|
|
# Check supporter limit
|
|
if not player.can_play_supporter(game.rules):
|
|
limit = game.rules.trainer.supporters_per_turn
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Supporter limit reached "
|
|
f"({player.supporters_played_this_turn}/{limit} this turn)",
|
|
)
|
|
|
|
# Check first turn restriction
|
|
restriction = _check_first_turn_restriction(
|
|
game, "can_play_supporter", "play Supporter cards"
|
|
)
|
|
if restriction is not None:
|
|
return restriction
|
|
|
|
elif trainer_type == TrainerType.STADIUM:
|
|
# Check stadium limit
|
|
if not player.can_play_stadium(game.rules):
|
|
limit = game.rules.trainer.stadiums_per_turn
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Stadium limit reached "
|
|
f"({player.stadiums_played_this_turn}/{limit} this turn)",
|
|
)
|
|
|
|
# Check if same stadium is already in play (unless same-name replace is enabled)
|
|
if game.stadium_in_play is not None:
|
|
current_stadium_def = _get_card_definition(game, game.stadium_in_play.definition_id)
|
|
is_same_name = current_stadium_def and current_stadium_def.name == definition.name
|
|
if is_same_name and not game.rules.trainer.stadium_same_name_replace:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot play '{definition.name}': same stadium already in play",
|
|
)
|
|
|
|
elif trainer_type == TrainerType.ITEM:
|
|
# Check item limit (if set)
|
|
if not player.can_play_item(game.rules):
|
|
limit = game.rules.trainer.items_per_turn
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Item limit reached ({player.items_played_this_turn}/{limit} this turn)",
|
|
)
|
|
|
|
elif trainer_type == TrainerType.TOOL:
|
|
# Tools attach to Pokemon - need a target
|
|
if not action.targets:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="Tool cards require a target Pokemon",
|
|
)
|
|
|
|
target_id = action.targets[0]
|
|
target = _get_pokemon_in_play(player, target_id)
|
|
if target is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Target Pokemon {target_id} not found in play",
|
|
)
|
|
|
|
# Check tool slot limit
|
|
max_tools = game.rules.trainer.tools_per_pokemon
|
|
current_tools = len(target.attached_tools)
|
|
if current_tools >= max_tools:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Pokemon already has maximum tools attached ({current_tools}/{max_tools})",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_use_ability(
|
|
game: GameState, player: PlayerState, action: UseAbilityAction
|
|
) -> ValidationResult:
|
|
"""Validate using a Pokemon's ability.
|
|
|
|
Checks:
|
|
- Pokemon is in play and belongs to player
|
|
- Ability index is valid
|
|
- Ability usage limit not exceeded
|
|
"""
|
|
# Check Pokemon is in play
|
|
pokemon = _get_pokemon_in_play(player, action.pokemon_id)
|
|
if pokemon is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Pokemon {action.pokemon_id} not found in play",
|
|
)
|
|
|
|
# Get Pokemon definition
|
|
definition = _get_card_definition(game, pokemon.definition_id)
|
|
if definition is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {pokemon.definition_id} not found",
|
|
)
|
|
|
|
# Check ability index is valid
|
|
if action.ability_index < 0 or action.ability_index >= len(definition.abilities):
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Invalid ability index {action.ability_index} "
|
|
f"(Pokemon has {len(definition.abilities)} abilities)",
|
|
)
|
|
|
|
# Get the ability
|
|
ability = definition.abilities[action.ability_index]
|
|
|
|
# Check ability usage limit
|
|
if not pokemon.can_use_ability(ability):
|
|
uses_limit = ability.uses_per_turn
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Ability '{ability.name}' already used "
|
|
f"({pokemon.ability_uses_this_turn}/{uses_limit} this turn)",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_attack(
|
|
game: GameState, player: PlayerState, action: AttackAction
|
|
) -> ValidationResult:
|
|
"""Validate declaring an attack.
|
|
|
|
Checks:
|
|
- Player has an active Pokemon
|
|
- Attack index is valid
|
|
- Pokemon has enough energy for the attack cost
|
|
- Pokemon is not Paralyzed or Asleep
|
|
- First turn attack restrictions
|
|
"""
|
|
# Check player has active Pokemon
|
|
active = player.get_active_pokemon()
|
|
if active is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="No active Pokemon to attack with",
|
|
)
|
|
|
|
# Get Pokemon definition
|
|
definition = _get_card_definition(game, active.definition_id)
|
|
if definition is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {active.definition_id} not found",
|
|
)
|
|
|
|
# Check attack index is valid
|
|
if action.attack_index < 0 or action.attack_index >= len(definition.attacks):
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Invalid attack index {action.attack_index} "
|
|
f"(Pokemon has {len(definition.attacks)} attacks)",
|
|
)
|
|
|
|
# Get the attack
|
|
attack = definition.attacks[action.attack_index]
|
|
|
|
# Get effective attack cost (may be modified)
|
|
effective_cost = active.effective_attack_cost(action.attack_index, attack.cost)
|
|
|
|
# Check energy cost
|
|
can_pay, error_msg = _can_pay_energy_cost(game, active, effective_cost)
|
|
if not can_pay:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot use '{attack.name}': {error_msg}",
|
|
)
|
|
|
|
# Check status conditions
|
|
blocking_status = _is_paralyzed_or_asleep(active)
|
|
if blocking_status is not None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot attack: Active Pokemon is {blocking_status.value}",
|
|
)
|
|
|
|
# Note: Confused Pokemon CAN attempt to attack (may fail at execution time)
|
|
|
|
# Check first turn restriction
|
|
restriction = _check_first_turn_restriction(game, "can_attack", "attack")
|
|
if restriction is not None:
|
|
return restriction
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_retreat(
|
|
game: GameState, player: PlayerState, action: RetreatAction
|
|
) -> ValidationResult:
|
|
"""Validate retreating the active Pokemon.
|
|
|
|
Checks:
|
|
- Player has an active Pokemon
|
|
- Player has a benched Pokemon to swap to
|
|
- New active is on the bench
|
|
- Can pay retreat cost with specified energy
|
|
- Pokemon is not Paralyzed or Asleep
|
|
- Retreat limit not exceeded
|
|
"""
|
|
# Check player has active Pokemon
|
|
active = player.get_active_pokemon()
|
|
if active is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="No active Pokemon to retreat",
|
|
)
|
|
|
|
# Check player has benched Pokemon
|
|
if not player.has_benched_pokemon():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="No benched Pokemon to switch to",
|
|
)
|
|
|
|
# Check new active is on bench
|
|
new_active = player.bench.get(action.new_active_id)
|
|
if new_active is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Pokemon {action.new_active_id} not found on bench",
|
|
)
|
|
|
|
# Get active Pokemon definition
|
|
definition = _get_card_definition(game, active.definition_id)
|
|
if definition is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Card definition {active.definition_id} not found",
|
|
)
|
|
|
|
# Calculate effective retreat cost
|
|
base_cost = definition.retreat_cost
|
|
effective_cost = active.effective_retreat_cost(base_cost)
|
|
|
|
# Check if free retreat is enabled in rules
|
|
if game.rules.retreat.free_retreat_cost:
|
|
effective_cost = 0
|
|
|
|
# Validate energy to discard
|
|
energy_count = len(action.energy_to_discard)
|
|
|
|
if energy_count < effective_cost:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Insufficient energy to retreat "
|
|
f"(need {effective_cost}, discarding {energy_count})",
|
|
)
|
|
|
|
# Check that all energy to discard is actually attached
|
|
# attached_energy is now a list of CardInstance objects
|
|
attached_energy_ids = [e.instance_id for e in active.attached_energy]
|
|
for energy_id in action.energy_to_discard:
|
|
if energy_id not in attached_energy_ids:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Energy {energy_id} is not attached to active Pokemon",
|
|
)
|
|
|
|
# Check status conditions
|
|
blocking_status = _is_paralyzed_or_asleep(active)
|
|
if blocking_status is not None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Cannot retreat: Active Pokemon is {blocking_status.value}",
|
|
)
|
|
|
|
# Check retreat limit
|
|
if not player.can_retreat(game.rules):
|
|
limit = game.rules.retreat.retreats_per_turn
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Retreat limit reached ({player.retreats_this_turn}/{limit} this turn)",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_pass(game: GameState, player: PlayerState, action: PassAction) -> ValidationResult:
|
|
"""Validate passing without taking an action.
|
|
|
|
Pass is always valid in the correct phases (main, attack).
|
|
Phase validation is done in the main validate_action function.
|
|
"""
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_select_prize(
|
|
game: GameState, player: PlayerState, action: SelectPrizeAction
|
|
) -> ValidationResult:
|
|
"""Validate selecting a prize card.
|
|
|
|
Checks:
|
|
- Game is using prize cards mode (not point-based)
|
|
- Prize index is valid
|
|
- Player has prizes remaining
|
|
"""
|
|
# Check game is using prize cards
|
|
if not game.rules.prizes.use_prize_cards:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="Game is not using prize cards (point-based scoring)",
|
|
)
|
|
|
|
# Check player has prizes
|
|
prize_count = len(player.prizes)
|
|
if prize_count == 0:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="No prize cards remaining",
|
|
)
|
|
|
|
# Check prize index is valid
|
|
if action.prize_index < 0 or action.prize_index >= prize_count:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Invalid prize index {action.prize_index} (valid range: 0-{prize_count - 1})",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_select_active(
|
|
game: GameState, player: PlayerState, action: SelectActiveAction
|
|
) -> ValidationResult:
|
|
"""Validate selecting a new active Pokemon.
|
|
|
|
This is used after a knockout when player needs to choose a new active.
|
|
|
|
Checks:
|
|
- Active zone is empty (post-KO state)
|
|
- Selected Pokemon is on the bench
|
|
"""
|
|
# For forced actions, we need to check the correct player
|
|
# The forced_action handling already ensures it's the right player
|
|
|
|
# Check active zone is empty (need to select new active)
|
|
if player.has_active_pokemon():
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason="Already have an active Pokemon",
|
|
)
|
|
|
|
# Check selected Pokemon is on bench
|
|
pokemon = player.bench.get(action.pokemon_id)
|
|
if pokemon is None:
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Pokemon {action.pokemon_id} not found on bench",
|
|
)
|
|
|
|
return ValidationResult(valid=True)
|
|
|
|
|
|
def _validate_resign(
|
|
game: GameState, player: PlayerState, action: ResignAction
|
|
) -> ValidationResult:
|
|
"""Validate resigning from the game.
|
|
|
|
Resign is always valid - a player can concede at any time.
|
|
"""
|
|
return ValidationResult(valid=True)
|