diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 60ce97e..bd62e71 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -110,6 +110,8 @@ class PrizeConfig(BaseModel): 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). + per_knockout_radiant: Points scored for knocking out a Radiant Pokemon (2 points). + per_knockout_prism_star: Points scored for knocking out a Prism Star Pokemon (2 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. @@ -122,6 +124,8 @@ class PrizeConfig(BaseModel): per_knockout_v: int = 2 per_knockout_vmax: int = 3 per_knockout_vstar: int = 3 + per_knockout_radiant: int = 2 + per_knockout_prism_star: int = 2 use_prize_cards: bool = False prize_selection_random: bool = True @@ -141,6 +145,8 @@ class PrizeConfig(BaseModel): PokemonVariant.V: self.per_knockout_v, PokemonVariant.VMAX: self.per_knockout_vmax, PokemonVariant.VSTAR: self.per_knockout_vstar, + PokemonVariant.RADIANT: self.per_knockout_radiant, + PokemonVariant.PRISM_STAR: self.per_knockout_prism_star, } return variant_map.get(variant, self.per_knockout_normal) diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 83a5e48..c842fc5 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -55,6 +55,8 @@ class PokemonVariant(StrEnum): - V: Worth 2 knockout points - VMAX: Evolves from V variant, worth 3 knockout points - VSTAR: Evolves from V variant, worth 3 knockout points, has VSTAR power + - RADIANT: Worth 2 knockout points, only 1 Radiant Pokemon allowed per deck + - PRISM_STAR: Worth 2 knockout points, only 1 per deck, goes to Lost Zone when KO'd """ NORMAL = "normal" @@ -63,6 +65,26 @@ class PokemonVariant(StrEnum): V = "v" VMAX = "vmax" VSTAR = "vstar" + RADIANT = "radiant" + PRISM_STAR = "prism_star" + + +class PokemonPowerType(StrEnum): + """Type of Pokemon Power or Ability (era-specific). + + Different eras of the Pokemon TCG used different terminology and rules + for special abilities: + + - POKEMON_POWER: Original Base-Fossil era. Can be "turned off" by certain effects. + - POKE_POWER: Neo era activated abilities. Require explicit activation. + - POKE_BODY: Neo era passive abilities. Always active, cannot be "turned off". + - ABILITY: Modern era (BW onwards). Unified term for all special abilities. + """ + + POKEMON_POWER = "pokemon_power" + POKE_POWER = "poke_power" + POKE_BODY = "poke_body" + ABILITY = "ability" class EnergyType(StrEnum): @@ -210,6 +232,7 @@ __all__ = [ "EnergyType", "GameEndReason", "ModifierMode", + "PokemonPowerType", "PokemonStage", "PokemonVariant", "StatusCondition", diff --git a/backend/app/core/models/card.py b/backend/app/core/models/card.py index 3bb9395..a3d8ed9 100644 --- a/backend/app/core/models/card.py +++ b/backend/app/core/models/card.py @@ -37,6 +37,7 @@ from app.core.enums import ( CardType, EnergyType, ModifierMode, + PokemonPowerType, PokemonStage, PokemonVariant, StatusCondition, @@ -60,6 +61,8 @@ class Attack(BaseModel): effect_id: Optional reference to an effect handler for special effects. effect_params: Parameters passed to the effect handler. effect_description: Human-readable description of the effect. + is_gx_attack: If True, this is a GX attack that can only be used once per game. + The game engine tracks GX attack usage per player, not per Pokemon. """ name: str @@ -69,6 +72,7 @@ class Attack(BaseModel): effect_id: str | None = None effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None + is_gx_attack: bool = False class Ability(BaseModel): @@ -84,6 +88,11 @@ class Ability(BaseModel): effect_description: Human-readable description of the ability. uses_per_turn: Maximum uses per turn. None means unlimited uses. Default is 1 (once per turn), which matches standard Pokemon TCG rules. + is_vstar_power: If True, this is a VSTAR Power that can only be used once per game. + The game engine tracks VSTAR Power usage per player, not per Pokemon. + power_type: The era-specific type of this ability. Affects how certain card + effects interact with it (e.g., "turn off Pokemon Powers" wouldn't affect + modern Abilities). Defaults to ABILITY for modern cards. """ name: str @@ -91,6 +100,8 @@ class Ability(BaseModel): effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None uses_per_turn: int | None = 1 # None = unlimited, 1 = once per turn (default) + is_vstar_power: bool = False + power_type: PokemonPowerType = PokemonPowerType.ABILITY class WeaknessResistance(BaseModel): @@ -170,8 +181,8 @@ class CardDefinition(BaseModel): name: Display name of the card. card_type: Whether this is a Pokemon, Trainer, or Energy card. stage: Evolution stage - BASIC, STAGE_1, or STAGE_2 (Pokemon only). - variant: Special variant - NORMAL, EX, GX, V, VMAX, VSTAR (Pokemon only). - Affects knockout points but not evolution mechanics. + variant: Special variant - NORMAL, EX, GX, V, VMAX, VSTAR, RADIANT, PRISM_STAR + (Pokemon only). Affects knockout points but not evolution mechanics. evolves_from: Name of the Pokemon this evolves from (Stage 1/2, VMAX, VSTAR only). hp: Hit points (Pokemon only). pokemon_type: Energy type of this Pokemon (for weakness/resistance). @@ -180,19 +191,27 @@ class CardDefinition(BaseModel): weakness: Weakness to an energy type (Pokemon only). resistance: Resistance to an energy type (Pokemon only). retreat_cost: Number of energy to discard to retreat (Pokemon only). + baby_rule: If True, this is a Baby Pokemon (Classic era). Opponents must flip + a coin before attacking; on tails, the attack fails. trainer_type: Subtype of trainer card (Trainer only). effect_id: Effect handler for this card (Trainer/special Energy). effect_params: Parameters for the effect handler. effect_description: Human-readable description of the card's effect. + is_ace_spec: If True, this is an ACE SPEC Trainer (Modern era). Only one + ACE SPEC card is allowed per deck. energy_type: Type of energy this card provides (Energy only). energy_provides: List of energy types this card provides. Basic energy provides one of its type. Special energy may provide multiple. + is_special_energy: If True, this is a Special Energy with additional effects. + special_energy_effect_id: Effect handler for special energy effects. rarity: Card rarity (common, uncommon, rare, etc.). set_id: Which card set this belongs to. image_url: URL to the card image (CDN). image_path: Local path to the card image (e.g., "pokemon/a1/001-bulbasaur.webp"). illustrator: Artist who illustrated this card. flavor_text: Flavor text on the card (Pokemon only, typically). + ruleset_tags: List of rulesets this card is designed for (e.g., ["pocket", "classic"]). + Used for filtering cards by game mode. """ id: str @@ -210,16 +229,20 @@ class CardDefinition(BaseModel): weakness: WeaknessResistance | None = None resistance: WeaknessResistance | None = None retreat_cost: int = 0 + baby_rule: bool = False # Classic era: flip coin to attack Baby Pokemon # Trainer-specific fields trainer_type: TrainerType | None = None effect_id: str | None = None effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None + is_ace_spec: bool = False # Modern: only 1 ACE SPEC card allowed per deck # Energy-specific fields energy_type: EnergyType | None = None energy_provides: list[EnergyType] = Field(default_factory=list) + is_special_energy: bool = False + special_energy_effect_id: str | None = None # Metadata rarity: str = "common" @@ -228,6 +251,7 @@ class CardDefinition(BaseModel): image_path: str | None = None # Local path: "pokemon/a1/001-bulbasaur.webp" illustrator: str | None = None flavor_text: str | None = None + ruleset_tags: list[str] = Field(default_factory=list) # e.g., ["pocket", "classic", "modern"] @model_validator(mode="after") def validate_card_type_fields(self) -> "CardDefinition": @@ -324,9 +348,35 @@ class CardDefinition(BaseModel): PokemonVariant.V: 2, PokemonVariant.VMAX: 3, PokemonVariant.VSTAR: 3, + PokemonVariant.RADIANT: 2, + PokemonVariant.PRISM_STAR: 2, } return points_map.get(self.variant, 1) + def has_rule_box(self) -> bool: + """Check if this Pokemon has a Rule Box. + + Pokemon with Rule Boxes are subject to various card effects that + specifically target or exclude them. All special variants (EX, GX, + V, VMAX, VSTAR, Radiant, Prism Star) have Rule Boxes. + + Returns: + True if this Pokemon has a Rule Box. + """ + if not self.is_pokemon(): + return False + + rule_box_variants = { + PokemonVariant.EX, + PokemonVariant.GX, + PokemonVariant.V, + PokemonVariant.VMAX, + PokemonVariant.VSTAR, + PokemonVariant.RADIANT, + PokemonVariant.PRISM_STAR, + } + return self.variant in rule_box_variants + class CardInstance(BaseModel): """A card instance in play with mutable state. diff --git a/backend/tests/core/test_models/test_enums.py b/backend/tests/core/test_models/test_enums.py index 09a4d5f..aa1c639 100644 --- a/backend/tests/core/test_models/test_enums.py +++ b/backend/tests/core/test_models/test_enums.py @@ -119,7 +119,9 @@ class TestPokemonVariant: assert PokemonVariant.V == "v" assert PokemonVariant.VMAX == "vmax" assert PokemonVariant.VSTAR == "vstar" - assert len(PokemonVariant) == 6 + assert PokemonVariant.RADIANT == "radiant" + assert PokemonVariant.PRISM_STAR == "prism_star" + assert len(PokemonVariant) == 8 def test_pokemon_variant_membership(self) -> None: """ @@ -136,7 +138,7 @@ class TestPokemonVariant: Document which variants are worth more knockout points. - NORMAL: 1 point (standard Pokemon) - - EX, GX, V: 2 points + - EX, GX, V, RADIANT, PRISM_STAR: 2 points - VMAX, VSTAR: 3 points """ one_point_variants = {PokemonVariant.NORMAL} @@ -144,6 +146,8 @@ class TestPokemonVariant: PokemonVariant.EX, PokemonVariant.GX, PokemonVariant.V, + PokemonVariant.RADIANT, + PokemonVariant.PRISM_STAR, } three_point_variants = {PokemonVariant.VMAX, PokemonVariant.VSTAR}