Add forced action handling, turn boundary detection, and DB persistence: - Check for pending forced actions before allowing regular actions - Only specified player can act during forced action (except resign) - Only specified action type allowed during forced action - Detect turn boundaries (turn number OR current player change) - Persist to Postgres at turn boundaries for durability - Include pending_forced_action in GameActionResult for client New exceptions: ForcedActionRequiredError Tests: 11 new tests covering forced actions, turn boundaries, and pending action reporting. Total 47 tests for GameService. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""Game rules configuration for Mantimon TCG.
|
|
|
|
This module defines the master configuration system for all game rules. The engine
|
|
is highly configurable to support both campaign mode (with fixed rules) and free
|
|
play mode (with user-adjustable rules).
|
|
|
|
Default values are based on the Mantimon TCG house rules documented in GAME_RULES.md,
|
|
which use a Pokemon Pocket-inspired energy system with a 40-card main deck and
|
|
separate 20-card energy deck.
|
|
|
|
Usage:
|
|
# Use default rules
|
|
rules = RulesConfig()
|
|
|
|
# Customize specific rules
|
|
rules = RulesConfig(
|
|
deck=DeckConfig(min_size=60, max_size=60),
|
|
prizes=PrizeConfig(count=6),
|
|
)
|
|
|
|
# Load from JSON
|
|
rules = RulesConfig.model_validate_json(json_string)
|
|
"""
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.core.enums import EnergyType, ModifierMode, PokemonVariant
|
|
|
|
|
|
class DeckConfig(BaseModel):
|
|
"""Configuration for deck building rules.
|
|
|
|
Attributes:
|
|
min_size: Minimum number of cards in the main deck.
|
|
max_size: Maximum number of cards in the main deck.
|
|
exact_size_required: If True, deck must be exactly min_size cards.
|
|
max_copies_per_card: Maximum copies of any single card (by name).
|
|
max_copies_basic_energy: Max copies of basic energy in energy deck.
|
|
None means unlimited.
|
|
min_basic_pokemon: Minimum number of Basic Pokemon required.
|
|
energy_deck_enabled: If True, use separate energy deck (Pokemon Pocket style).
|
|
energy_deck_size: Size of the separate energy deck.
|
|
starting_hand_size: Number of cards drawn at game start.
|
|
"""
|
|
|
|
min_size: int = 40
|
|
max_size: int = 40
|
|
exact_size_required: bool = True
|
|
max_copies_per_card: int = 4
|
|
max_copies_basic_energy: int | None = None
|
|
min_basic_pokemon: int = 1
|
|
energy_deck_enabled: bool = True
|
|
energy_deck_size: int = 20
|
|
starting_hand_size: int = 7
|
|
|
|
|
|
class ActiveConfig(BaseModel):
|
|
"""Configuration for active Pokemon slot rules.
|
|
|
|
Supports standard single-battle (1 active) or double-battle variants
|
|
(2 active Pokemon per player).
|
|
|
|
Attributes:
|
|
max_active: Maximum number of Pokemon in the active position.
|
|
Default is 1 (standard single battle). Set to 2 for double battles.
|
|
"""
|
|
|
|
max_active: int = 1
|
|
|
|
|
|
class BenchConfig(BaseModel):
|
|
"""Configuration for bench rules.
|
|
|
|
Attributes:
|
|
max_size: Maximum number of Pokemon on the bench.
|
|
"""
|
|
|
|
max_size: int = 5
|
|
|
|
|
|
class EnergyConfig(BaseModel):
|
|
"""Configuration for energy attachment rules.
|
|
|
|
Attributes:
|
|
attachments_per_turn: Number of energy cards that can be attached per turn.
|
|
types_enabled: List of energy types available in this game.
|
|
auto_flip_from_deck: If True, flip top card of energy deck at turn start
|
|
(Pokemon Pocket style).
|
|
"""
|
|
|
|
attachments_per_turn: int = 1
|
|
types_enabled: list[EnergyType] = Field(default_factory=lambda: list(EnergyType))
|
|
auto_flip_from_deck: bool = True
|
|
|
|
|
|
class PrizeConfig(BaseModel):
|
|
"""Configuration for prize/scoring rules.
|
|
|
|
In core Mantimon TCG rules, "prizes" are replaced with "points" - players
|
|
score points instead of taking prize cards. This simplifies the game while
|
|
maintaining the knockout scoring mechanic.
|
|
|
|
Knockout points are determined by the Pokemon's variant (EX, V, GX, etc.). A Basic EX is worth the same as a Stage 2 EX.
|
|
|
|
Attributes:
|
|
count: Number of points needed to win (or prize cards if using classic rules).
|
|
per_knockout_normal: Points for knocking out a normal (non-variant) Pokemon.
|
|
per_knockout_ex: Points scored for knocking out an EX Pokemon.
|
|
per_knockout_gx: Points scored for knocking out a GX Pokemon.
|
|
per_knockout_v: Points scored for knocking out a V Pokemon.
|
|
per_knockout_vmax: Points scored for knocking out a VMAX Pokemon.
|
|
per_knockout_vstar: Points scored for knocking out a VSTAR Pokemon (3 points).
|
|
use_prize_cards: If True, use classic prize card mechanic instead of points.
|
|
prize_selection_random: If True, prize cards are taken randomly (classic).
|
|
If False, player chooses which prize to take.
|
|
"""
|
|
|
|
count: int = 4
|
|
per_knockout_normal: int = 1
|
|
per_knockout_ex: int = 2
|
|
per_knockout_gx: int = 2
|
|
per_knockout_v: int = 2
|
|
per_knockout_vmax: int = 3
|
|
per_knockout_vstar: int = 3
|
|
use_prize_cards: bool = False
|
|
prize_selection_random: bool = True
|
|
|
|
def points_for_knockout(self, variant: PokemonVariant) -> int:
|
|
"""Get the number of points scored for knocking out a Pokemon of the given variant.
|
|
|
|
Args:
|
|
variant: The PokemonVariant of the knocked out Pokemon.
|
|
|
|
Returns:
|
|
Number of points to score.
|
|
"""
|
|
variant_map = {
|
|
PokemonVariant.NORMAL: self.per_knockout_normal,
|
|
PokemonVariant.EX: self.per_knockout_ex,
|
|
PokemonVariant.GX: self.per_knockout_gx,
|
|
PokemonVariant.V: self.per_knockout_v,
|
|
PokemonVariant.VMAX: self.per_knockout_vmax,
|
|
PokemonVariant.VSTAR: self.per_knockout_vstar,
|
|
}
|
|
return variant_map.get(variant, self.per_knockout_normal)
|
|
|
|
|
|
class FirstTurnConfig(BaseModel):
|
|
"""Configuration for first turn restrictions.
|
|
|
|
These rules apply only to the very first turn of the game (turn 1 for player 1).
|
|
|
|
Attributes:
|
|
can_draw: Whether the first player draws a card on turn 1.
|
|
can_attack: Whether the first player can attack on turn 1.
|
|
can_play_supporter: Whether the first player can play Supporter cards on turn 1.
|
|
can_attach_energy: Whether the first player can attach energy on turn 1.
|
|
can_evolve: Whether the first player can evolve Pokemon on turn 1.
|
|
"""
|
|
|
|
can_draw: bool = True
|
|
can_attack: bool = True
|
|
can_play_supporter: bool = True
|
|
can_attach_energy: bool = False
|
|
can_evolve: bool = False
|
|
|
|
|
|
class WinConditionsConfig(BaseModel):
|
|
"""Configuration for win/loss conditions.
|
|
|
|
Each condition can be enabled or disabled independently. A player wins
|
|
when any enabled win condition is met.
|
|
|
|
Attributes:
|
|
all_prizes_taken: Win when a player scores the required number of points.
|
|
no_pokemon_in_play: Win when opponent has no Pokemon in play.
|
|
cannot_draw: Win when opponent cannot draw a card at turn start.
|
|
turn_limit_enabled: Enable maximum turn count (useful for AI matches).
|
|
turn_limit: Maximum number of turns before game ends. Each player's
|
|
turn counts as one turn (so 30 = 15 turns per player).
|
|
turn_timer_enabled: Enable per-turn time limits (multiplayer).
|
|
turn_timer_seconds: Seconds per turn before timeout (default 90).
|
|
game_timer_enabled: Enable total game time limit (multiplayer).
|
|
game_timer_minutes: Total game time in minutes.
|
|
"""
|
|
|
|
all_prizes_taken: bool = True
|
|
no_pokemon_in_play: bool = True
|
|
cannot_draw: bool = True
|
|
turn_limit_enabled: bool = True
|
|
turn_limit: int = 30
|
|
turn_timer_enabled: bool = False
|
|
turn_timer_seconds: int = 90
|
|
game_timer_enabled: bool = False
|
|
game_timer_minutes: int = 30
|
|
|
|
|
|
class StatusConfig(BaseModel):
|
|
"""Configuration for status condition effects.
|
|
|
|
Defines the damage values and removal mechanics for each status condition.
|
|
|
|
Attributes:
|
|
poison_damage: Damage dealt by Poison between turns.
|
|
burn_damage: Damage dealt by Burn between turns.
|
|
burn_flip_to_remove: If True, flip coin between turns; heads removes Burn.
|
|
sleep_flip_to_wake: If True, flip coin between turns; heads removes Sleep.
|
|
confusion_self_damage: Damage dealt to self on failed confusion flip.
|
|
"""
|
|
|
|
poison_damage: int = 10
|
|
burn_damage: int = 20
|
|
burn_flip_to_remove: bool = True
|
|
sleep_flip_to_wake: bool = True
|
|
confusion_self_damage: int = 30
|
|
|
|
|
|
class TrainerConfig(BaseModel):
|
|
"""Configuration for Trainer card rules.
|
|
|
|
Attributes:
|
|
supporters_per_turn: Maximum Supporter cards playable per turn.
|
|
stadiums_per_turn: Maximum Stadium cards playable per turn.
|
|
items_per_turn: Maximum Item cards per turn. None means unlimited.
|
|
tools_per_pokemon: Maximum Tool cards attachable to one Pokemon.
|
|
stadium_same_name_replace: If True, a stadium can replace another stadium
|
|
with the same name. If False (default), you cannot play a stadium if
|
|
a stadium with the same name is already in play. Standard Pokemon TCG
|
|
rules prohibit same-name stadium replacement.
|
|
"""
|
|
|
|
supporters_per_turn: int = 1
|
|
stadiums_per_turn: int = 1
|
|
items_per_turn: int | None = None
|
|
tools_per_pokemon: int = 1
|
|
stadium_same_name_replace: bool = False
|
|
|
|
|
|
class EvolutionConfig(BaseModel):
|
|
"""Configuration for evolution rules.
|
|
|
|
Attributes:
|
|
same_turn_as_played: Can evolve a Pokemon the same turn it was played.
|
|
same_turn_as_evolution: Can evolve a Pokemon the same turn it evolved.
|
|
first_turn_of_game: Can evolve on the very first turn of the game.
|
|
"""
|
|
|
|
same_turn_as_played: bool = False
|
|
same_turn_as_evolution: bool = False
|
|
first_turn_of_game: bool = False
|
|
|
|
|
|
class RetreatConfig(BaseModel):
|
|
"""Configuration for retreat rules.
|
|
|
|
Attributes:
|
|
retreats_per_turn: Maximum number of retreats allowed per turn.
|
|
free_retreat_cost: If True, retreating doesn't require discarding energy.
|
|
"""
|
|
|
|
retreats_per_turn: int = 1
|
|
free_retreat_cost: bool = False
|
|
|
|
|
|
class CombatConfig(BaseModel):
|
|
"""Configuration for combat damage calculations.
|
|
|
|
Controls how weakness and resistance modify damage. Standard Pokemon TCG uses
|
|
multiplicative weakness (x2) and additive resistance (-30), but these can be
|
|
customized for house rules or game variants.
|
|
|
|
Cards can override these defaults via their WeaknessResistance definitions.
|
|
|
|
Attributes:
|
|
weakness_mode: How weakness modifies damage (multiplicative or additive).
|
|
weakness_value: Default weakness modifier value.
|
|
- For multiplicative: damage * value (e.g., 2 for x2)
|
|
- For additive: damage + value (e.g., 20 for +20)
|
|
resistance_mode: How resistance modifies damage (multiplicative or additive).
|
|
resistance_value: Default resistance modifier value.
|
|
- For multiplicative: damage * value (e.g., 0.5 for half damage)
|
|
- For additive: damage + value (e.g., -30 for -30 damage)
|
|
|
|
Examples:
|
|
Standard Pokemon TCG (x2 weakness, -30 resistance):
|
|
CombatConfig(
|
|
weakness_mode=ModifierMode.MULTIPLICATIVE,
|
|
weakness_value=2,
|
|
resistance_mode=ModifierMode.ADDITIVE,
|
|
resistance_value=-30,
|
|
)
|
|
|
|
Additive weakness/resistance (+20/-20):
|
|
CombatConfig(
|
|
weakness_mode=ModifierMode.ADDITIVE,
|
|
weakness_value=20,
|
|
resistance_mode=ModifierMode.ADDITIVE,
|
|
resistance_value=-20,
|
|
)
|
|
"""
|
|
|
|
weakness_mode: ModifierMode = ModifierMode.MULTIPLICATIVE
|
|
weakness_value: int = 2
|
|
resistance_mode: ModifierMode = ModifierMode.ADDITIVE
|
|
resistance_value: int = -30
|
|
|
|
|
|
class RulesConfig(BaseModel):
|
|
"""Master configuration for all game rules.
|
|
|
|
This is the top-level configuration object that contains all rule settings.
|
|
Default values are based on Mantimon TCG house rules (Pokemon Pocket-inspired
|
|
with 40-card decks, separate energy deck, and 4 points to win).
|
|
|
|
For standard Pokemon TCG rules, override with:
|
|
RulesConfig(
|
|
deck=DeckConfig(min_size=60, max_size=60, energy_deck_enabled=False),
|
|
prizes=PrizeConfig(count=6, use_prize_cards=True),
|
|
first_turn=FirstTurnConfig(can_attack=False, can_attach_energy=True),
|
|
)
|
|
|
|
Attributes:
|
|
deck: Deck building configuration.
|
|
active: Active Pokemon slot configuration.
|
|
bench: Bench configuration.
|
|
energy: Energy attachment configuration.
|
|
prizes: Prize/scoring configuration.
|
|
first_turn: First turn restrictions.
|
|
win_conditions: Win/loss condition configuration.
|
|
status: Status condition effect configuration.
|
|
trainer: Trainer card rule configuration.
|
|
evolution: Evolution rule configuration.
|
|
retreat: Retreat rule configuration.
|
|
combat: Combat damage calculation configuration.
|
|
"""
|
|
|
|
deck: DeckConfig = Field(default_factory=DeckConfig)
|
|
active: ActiveConfig = Field(default_factory=ActiveConfig)
|
|
bench: BenchConfig = Field(default_factory=BenchConfig)
|
|
energy: EnergyConfig = Field(default_factory=EnergyConfig)
|
|
prizes: PrizeConfig = Field(default_factory=PrizeConfig)
|
|
first_turn: FirstTurnConfig = Field(default_factory=FirstTurnConfig)
|
|
win_conditions: WinConditionsConfig = Field(default_factory=WinConditionsConfig)
|
|
status: StatusConfig = Field(default_factory=StatusConfig)
|
|
trainer: TrainerConfig = Field(default_factory=TrainerConfig)
|
|
evolution: EvolutionConfig = Field(default_factory=EvolutionConfig)
|
|
retreat: RetreatConfig = Field(default_factory=RetreatConfig)
|
|
combat: CombatConfig = Field(default_factory=CombatConfig)
|
|
|
|
@classmethod
|
|
def standard_pokemon_tcg(cls) -> "RulesConfig":
|
|
"""Create a configuration approximating standard Pokemon TCG rules.
|
|
|
|
Returns:
|
|
RulesConfig with settings closer to official Pokemon TCG.
|
|
"""
|
|
return cls(
|
|
deck=DeckConfig(
|
|
min_size=60,
|
|
max_size=60,
|
|
energy_deck_enabled=False,
|
|
),
|
|
prizes=PrizeConfig(
|
|
count=6,
|
|
use_prize_cards=True,
|
|
),
|
|
first_turn=FirstTurnConfig(
|
|
can_attack=False,
|
|
can_play_supporter=False,
|
|
can_attach_energy=True,
|
|
),
|
|
)
|