mantimon-tcg/backend/app/core/models/actions.py
Cal Corum 5e99566560 Add rules validator, win conditions checker, and coverage gap tests
- Implement rules_validator.py with config-driven action validation for all 11 action types
- Implement win_conditions.py with point/prize-based, knockout, deck-out, turn limit, and timeout checks
- Add ForcedAction model to GameState for blocking actions (e.g., select new active after KO)
- Add ActiveConfig with max_active setting for future double-battle support
- Add TrainerConfig.stadium_same_name_replace option
- Add DeckConfig.starting_hand_size option
- Rename from_energy_deck to from_energy_zone for consistency
- Fix unreachable code bug in GameState.get_opponent_id()
- Add 16 coverage gap tests for edge cases (card registry corruption, forced actions, etc.)
- 584 tests passing at 97% coverage

Completes HIGH-005, HIGH-006, TEST-009, TEST-010 from PROJECT_PLAN.json
2026-01-25 12:57:06 -06:00

281 lines
8.9 KiB
Python

"""Player action models for the Mantimon TCG game engine.
This module defines all actions a player can take during their turn.
Actions are modeled as a discriminated union using Pydantic's Literal types,
enabling type-safe action handling and automatic JSON serialization.
The union type allows code like:
match action:
case PlayPokemonAction():
handle_play_pokemon(action)
case AttackAction():
handle_attack(action)
Usage:
# Create an action
action = AttackAction(attack_index=0)
# Serialize to JSON for WebSocket
json_str = action.model_dump_json()
# Parse incoming action from client
data = {"type": "attack", "attack_index": 0}
action = parse_action(data) # Returns AttackAction
"""
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field
class PlayPokemonAction(BaseModel):
"""Play a Basic Pokemon from hand to the bench (or active during setup).
This action places a Basic Pokemon card from the player's hand onto their
bench. During the setup phase, it can optionally be placed as the active
Pokemon.
Attributes:
type: Discriminator field, always "play_pokemon".
card_instance_id: The CardInstance.instance_id of the Pokemon to play.
to_active: If True and during setup, place as active Pokemon.
Ignored during normal gameplay.
"""
type: Literal["play_pokemon"] = "play_pokemon"
card_instance_id: str
to_active: bool = False
class EvolvePokemonAction(BaseModel):
"""Evolve a Pokemon in play.
Places an evolution card from hand onto a Pokemon in play, provided
evolution rules are satisfied (not same turn as played/evolved, etc.).
Attributes:
type: Discriminator field, always "evolve".
evolution_card_id: The CardInstance.instance_id of the evolution card.
target_pokemon_id: The CardInstance.instance_id of the Pokemon to evolve.
"""
type: Literal["evolve"] = "evolve"
evolution_card_id: str
target_pokemon_id: str
class AttachEnergyAction(BaseModel):
"""Attach an energy card from hand (or energy zone) to a Pokemon.
Energy can be attached to the active Pokemon or any benched Pokemon.
Limited to once per turn by default (configurable via RulesConfig).
In Pokemon Pocket style gameplay, energy is flipped from the energy_deck
to the energy_zone at turn start. The player can then attach from the
energy_zone rather than from hand.
Attributes:
type: Discriminator field, always "attach_energy".
energy_card_id: The CardInstance.instance_id of the energy card.
target_pokemon_id: The CardInstance.instance_id of the Pokemon to attach to.
from_energy_zone: If True, the energy comes from the energy zone
(Pokemon Pocket style) rather than hand.
"""
type: Literal["attach_energy"] = "attach_energy"
energy_card_id: str
target_pokemon_id: str
from_energy_zone: bool = False
class PlayTrainerAction(BaseModel):
"""Play a Trainer card from hand.
The effect of the trainer card is resolved by the effect handler system.
Some trainer cards require targets (specified in the targets list).
Attributes:
type: Discriminator field, always "play_trainer".
card_instance_id: The CardInstance.instance_id of the trainer card.
targets: List of target CardInstance IDs for cards that require targeting.
The specific meaning depends on the card's effect.
additional_params: Extra parameters for complex trainer effects.
"""
type: Literal["play_trainer"] = "play_trainer"
card_instance_id: str
targets: list[str] = Field(default_factory=list)
additional_params: dict[str, Any] = Field(default_factory=dict)
class UseAbilityAction(BaseModel):
"""Use an ability on a Pokemon in play.
Abilities are typically limited to once per turn per Pokemon
(configurable per ability).
Attributes:
type: Discriminator field, always "use_ability".
pokemon_id: The CardInstance.instance_id of the Pokemon with the ability.
ability_index: Index of the ability in the Pokemon's abilities list.
targets: List of target CardInstance IDs if the ability requires targeting.
additional_params: Extra parameters for complex ability effects.
"""
type: Literal["use_ability"] = "use_ability"
pokemon_id: str
ability_index: int = 0
targets: list[str] = Field(default_factory=list)
additional_params: dict[str, Any] = Field(default_factory=dict)
class AttackAction(BaseModel):
"""Declare an attack with the active Pokemon.
This action can only be performed during the attack phase and ends the turn.
The attack's effect is resolved by the effect handler system.
Attributes:
type: Discriminator field, always "attack".
attack_index: Index of the attack in the active Pokemon's attacks list.
targets: List of target CardInstance IDs for attacks that allow targeting.
Most attacks target the defending Pokemon automatically.
additional_params: Extra parameters for complex attack effects.
"""
type: Literal["attack"] = "attack"
attack_index: int = 0
targets: list[str] = Field(default_factory=list)
additional_params: dict[str, Any] = Field(default_factory=dict)
class RetreatAction(BaseModel):
"""Retreat the active Pokemon, switching with a benched Pokemon.
Requires discarding energy equal to the retreat cost (unless modified
by effects). Limited to once per turn by default.
Attributes:
type: Discriminator field, always "retreat".
new_active_id: The CardInstance.instance_id of the benched Pokemon
to become the new active.
energy_to_discard: List of CardInstance IDs of energy cards to discard
as the retreat cost.
"""
type: Literal["retreat"] = "retreat"
new_active_id: str
energy_to_discard: list[str] = Field(default_factory=list)
class PassAction(BaseModel):
"""Pass without taking an action, ending the current phase.
During the main phase, this advances to the attack phase.
During the attack phase, this ends the turn without attacking.
Attributes:
type: Discriminator field, always "pass".
"""
type: Literal["pass"] = "pass"
class SelectPrizeAction(BaseModel):
"""Select a prize card to take after a knockout.
Only used when using classic prize card rules (not the default point system).
Attributes:
type: Discriminator field, always "select_prize".
prize_index: Index of the prize card to take (0-5 for 6 prizes).
"""
type: Literal["select_prize"] = "select_prize"
prize_index: int
class SelectActiveAction(BaseModel):
"""Select a new active Pokemon after the current active is knocked out.
Attributes:
type: Discriminator field, always "select_active".
pokemon_id: The CardInstance.instance_id of the benched Pokemon
to become the new active.
"""
type: Literal["select_active"] = "select_active"
pokemon_id: str
class ResignAction(BaseModel):
"""Resign from the match, conceding victory to the opponent.
Attributes:
type: Discriminator field, always "resign".
"""
type: Literal["resign"] = "resign"
# Union type for all player actions
# Using Annotated with Field(discriminator) for automatic type resolution
Action = Annotated[
PlayPokemonAction
| EvolvePokemonAction
| AttachEnergyAction
| PlayTrainerAction
| UseAbilityAction
| AttackAction
| RetreatAction
| PassAction
| SelectPrizeAction
| SelectActiveAction
| ResignAction,
Field(discriminator="type"),
]
def parse_action(data: dict[str, Any]) -> Action:
"""Parse an action from a dictionary (e.g., from JSON).
Uses the 'type' field to determine which action model to use.
Args:
data: Dictionary containing action data with a 'type' field.
Returns:
The appropriate Action subtype.
Raises:
ValueError: If the action type is unknown.
ValidationError: If the data doesn't match the action schema.
Example:
data = {"type": "attack", "attack_index": 0}
action = parse_action(data)
assert isinstance(action, AttackAction)
"""
from pydantic import TypeAdapter
adapter = TypeAdapter(Action)
return adapter.validate_python(data)
# Mapping of action types to their valid phases
# This is used by the rules validator to check if an action is valid
# in the current phase
VALID_PHASES_FOR_ACTION: dict[str, list[str]] = {
"play_pokemon": ["setup", "main"],
"evolve": ["main"],
"attach_energy": ["main"],
"play_trainer": ["main"],
"use_ability": ["main"],
"attack": ["attack"],
"retreat": ["main"],
"pass": ["main", "attack"],
"select_prize": ["end"],
"select_active": ["main", "end"], # After knockout
"resign": ["setup", "draw", "main", "attack", "end"], # Any time
}