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.
963 lines
32 KiB
Python
963 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.enums import (
|
|
EnergyType,
|
|
StatusCondition,
|
|
TrainerType,
|
|
TurnPhase,
|
|
)
|
|
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.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.has_forced_action():
|
|
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. Only the first action in the
|
|
queue is checked - subsequent actions are handled after completion.
|
|
"""
|
|
forced = game.get_current_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, action.ability_index):
|
|
uses_limit = ability.uses_per_turn
|
|
current_uses = pokemon.get_ability_uses(action.ability_index)
|
|
return ValidationResult(
|
|
valid=False,
|
|
reason=f"Ability '{ability.name}' already used ({current_uses}/{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)
|