mantimon-tcg/backend/app/core/rules_validator.py
Cal Corum e7431e2d1f Move enums to app/core/enums.py and set up clean module exports
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.
2026-01-26 14:45:26 -06:00

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)