Extend core card models with rarity tiers and card subtypes

Add CardRarityTier enum for pull rate calculations (common through
crown_rare). Add CardSubtype enum for Pokemon classifications (basic,
stage1, stage2, ex, etc.). Update CardDefinition model with new fields
for subtypes and rarity display.
This commit is contained in:
Cal Corum 2026-01-31 15:43:07 -06:00
parent 7b79f02124
commit 1123d61067
4 changed files with 87 additions and 4 deletions

View File

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

View File

@ -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",

View File

@ -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.

View File

@ -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}