"""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)