mantimon-tcg/docs/ACTIVE_EFFECTS_DESIGN.md
Cal Corum 3ed67ea16b Add Active Effects system design and module README files
Design Documentation:
- docs/ACTIVE_EFFECTS_DESIGN.md: Comprehensive design for persistent effect system
  - Data model (ActiveEffect, EffectTrigger, EffectScope, StackingMode)
  - Core operations (register, remove, query effects)
  - Integration points (damage calc, energy counting, retreat, lifecycle)
  - Effect categories from Pokemon Pocket card research (~372 cards)
  - Example implementations (Serperior, Greninja, Mr. Mime, Victreebel)
  - Post-launch TODO for generic modifier system

Module README Files:
- backend/app/core/README.md: Core engine overview and key classes
- backend/app/core/effects/README.md: Effects module index and quick reference
- backend/app/core/models/README.md: Models module with relationship diagram

Minor cleanup:
- Revert Bulbasaur weakness to Fire (was test change for Lightning)
- Clean up debug output in game walkthrough
2026-01-26 22:39:02 -06:00

24 KiB

Active Effects System - Design Document

Overview

The Active Effects System provides a centralized registry for persistent game modifiers that affect gameplay across multiple cards, turns, or the entire game. This system enables abilities, stadiums, and other cards to create lasting effects that modify damage calculations, energy counting, retreat costs, status immunity, and more.

Goals

  1. Flexibility: Support many types of persistent effects without hardcoding logic
  2. Discoverability: Easy to query which effects are active and affecting a calculation
  3. Lifecycle Management: Automatic cleanup when source cards leave play
  4. Stacking Rules: Configurable rules for how multiple similar effects interact
  5. Debuggability: Clear visibility into what effects are modifying game state

Non-Goals (For Initial Implementation)

  • Complex effect chains (effect A triggers effect B)
  • Priority/speed systems (all effects of same type resolve together)
  • Undo/replay support (effects are applied immediately)

Data Model

Enumerations

class EffectTrigger(StrEnum):
    """When the effect should be evaluated."""
    CONTINUOUS = "continuous"        # Always active while source is in play
    ON_DAMAGE_CALC = "on_damage_calc"      # During damage calculation
    ON_ENERGY_COUNT = "on_energy_count"    # When counting energy for attack costs
    ON_RETREAT_CALC = "on_retreat_calc"    # When calculating retreat cost
    ON_STATUS_APPLY = "on_status_apply"    # When a status would be applied
    ON_TURN_START = "on_turn_start"        # At the start of a turn
    ON_TURN_END = "on_turn_end"            # At the end of a turn
    ON_ATTACK = "on_attack"                # When an attack is declared
    ON_KNOCKOUT = "on_knockout"            # When a Pokemon is knocked out


class EffectScope(StrEnum):
    """Who/what the effect applies to."""
    SELF = "self"                    # Only the source card
    TEAM_ACTIVE = "team_active"      # Owner's active Pokemon only
    TEAM_BENCH = "team_bench"        # Owner's benched Pokemon only
    TEAM_ALL = "team_all"            # All of owner's Pokemon
    OPPONENT_ACTIVE = "opponent_active"    # Opponent's active Pokemon
    OPPONENT_BENCH = "opponent_bench"      # Opponent's benched Pokemon
    OPPONENT_ALL = "opponent_all"          # All opponent's Pokemon
    ALL_POKEMON = "all_pokemon"            # Every Pokemon in play
    GLOBAL = "global"                      # Affects game rules


class StackingMode(StrEnum):
    """How multiple instances of the same effect combine."""
    ADDITIVE = "additive"      # Values sum (e.g., -10 + -10 = -20)
    MAX_ONLY = "max_only"      # Only strongest applies
    MIN_ONLY = "min_only"      # Only weakest applies
    NO_STACK = "no_stack"      # Only one instance allowed

ActiveEffect Model

class ActiveEffect(BaseModel):
    """A persistent effect active in the game.
    
    Active effects are registered when certain cards enter play (abilities,
    stadiums) or when certain actions occur. They are automatically removed
    when their source leaves play.
    
    Attributes:
        id: Unique identifier for this effect instance.
        effect_type: Category identifier for querying (e.g., "damage_reduction").
        source_type: What created this effect ("ability", "stadium", "attack", "trainer").
        source_card_id: Instance ID of the card providing this effect.
        source_player_id: Player who owns/controls the source.
        trigger: When this effect should be evaluated.
        scope: What this effect applies to.
        stacking_mode: How multiple instances combine.
        stacking_key: Key for stacking rules (effects with same key interact).
        params: Effect-specific parameters.
        duration: How long the effect lasts (None = until source leaves play).
        expires_turn: Turn number when effect expires (if duration-based).
        suppressed: If True, effect is temporarily disabled (e.g., by ability lock).
    """
    
    id: str
    effect_type: str
    source_type: str
    source_card_id: str | None = None  # None for game-wide effects
    source_player_id: str
    trigger: EffectTrigger = EffectTrigger.CONTINUOUS
    scope: EffectScope = EffectScope.SELF
    stacking_mode: StackingMode = StackingMode.ADDITIVE
    stacking_key: str | None = None  # For grouping similar effects
    params: dict[str, Any] = Field(default_factory=dict)
    duration: int | None = None  # None = permanent until source removed
    expires_turn: int | None = None
    suppressed: bool = False

GameState Addition

class GameState(BaseModel):
    # ... existing fields ...
    
    # Active effects registry
    active_effects: list[ActiveEffect] = Field(default_factory=list)

Core Operations

ActiveEffectsManager

class ActiveEffectsManager:
    """Manages the lifecycle and querying of active effects."""
    
    def register_effect(
        self,
        game: GameState,
        effect_type: str,
        source_type: str,
        source_card_id: str | None,
        source_player_id: str,
        trigger: EffectTrigger,
        scope: EffectScope,
        params: dict[str, Any],
        stacking_mode: StackingMode = StackingMode.ADDITIVE,
        stacking_key: str | None = None,
        duration: int | None = None,
    ) -> ActiveEffect:
        """Register a new active effect.
        
        Generates a unique ID and adds the effect to the game's registry.
        Handles stacking rules if an effect with the same stacking_key exists.
        """
        ...
    
    def remove_effects_by_source(self, game: GameState, source_card_id: str) -> int:
        """Remove all effects from a specific source card.
        
        Called when a Pokemon leaves play (knockout, evolution, returned to hand).
        Returns the number of effects removed.
        """
        ...
    
    def remove_expired_effects(self, game: GameState) -> int:
        """Remove effects that have expired based on turn count."""
        ...
    
    def query_effects(
        self,
        game: GameState,
        effect_type: str | None = None,
        trigger: EffectTrigger | None = None,
        affecting_player_id: str | None = None,
        affecting_card_id: str | None = None,
        include_suppressed: bool = False,
    ) -> list[ActiveEffect]:
        """Query active effects matching the given criteria."""
        ...
    
    def get_combined_value(
        self,
        effects: list[ActiveEffect],
        param_key: str,
        base_value: int = 0,
    ) -> int:
        """Combine multiple effects into a single value respecting stacking modes."""
        ...

Integration Points

1. Damage Calculation

The existing _calculate_attack_damage method would be extended:

def _calculate_attack_damage(self, game, attacker, attacker_def, defender, defender_def, base_damage):
    damage = base_damage
    
    # Step 1: Attacker's damage modifier (existing)
    # Step 2: Query active effects - damage boost (attacker's team)
    boost_effects = self.effects_manager.query_effects(
        game,
        effect_type="damage_boost",
        trigger=EffectTrigger.ON_DAMAGE_CALC,
        affecting_card_id=attacker.instance_id,
    )
    if boost_effects:
        boost = self.effects_manager.get_combined_value(boost_effects, "amount")
        damage += boost
    
    # Step 3: Weakness (existing)
    # Step 4: Resistance (existing)
    
    # Step 5: Query active effects - damage reduction (defender's team)
    reduction_effects = self.effects_manager.query_effects(
        game,
        effect_type="damage_reduction",
        trigger=EffectTrigger.ON_DAMAGE_CALC,
        affecting_card_id=defender.instance_id,
    )
    if reduction_effects:
        reduction = self.effects_manager.get_combined_value(reduction_effects, "amount")
        damage -= reduction
    
    return max(0, damage)

2. Energy Counting

def count_effective_energy(
    self,
    game: GameState,
    pokemon: CardInstance,
    player_id: str,
) -> dict[EnergyType, int]:
    """Count energy attached to a Pokemon, applying any multiplier effects."""
    counts: dict[EnergyType, int] = defaultdict(int)
    
    for energy in pokemon.attached_energy:
        energy_def = game.get_card_definition(energy.definition_id)
        for provided_type in energy_def.energy_provides:
            base_count = 1
            
            # Query energy multiplier effects
            multiplier_effects = self.effects_manager.query_effects(
                game,
                effect_type="energy_multiplier",
                trigger=EffectTrigger.ON_ENERGY_COUNT,
                affecting_card_id=pokemon.instance_id,
            )
            
            for effect in multiplier_effects:
                if effect.params.get("energy_type") == provided_type.value:
                    base_count *= effect.params.get("multiplier", 1)
            
            counts[provided_type] += base_count
    
    return counts

3. Retreat Cost Calculation

def calculate_retreat_cost(
    self,
    game: GameState,
    pokemon: CardInstance,
    player_id: str,
) -> int:
    """Calculate effective retreat cost with modifiers."""
    card_def = game.get_card_definition(pokemon.definition_id)
    base_cost = card_def.retreat_cost if card_def else 0
    cost = base_cost + pokemon.retreat_cost_modifier
    
    # Query retreat cost reduction effects
    reduction_effects = self.effects_manager.query_effects(
        game,
        effect_type="retreat_cost_reduction",
        trigger=EffectTrigger.ON_RETREAT_CALC,
        affecting_card_id=pokemon.instance_id,
    )
    
    if reduction_effects:
        reduction = self.effects_manager.get_combined_value(reduction_effects, "amount")
        cost -= reduction
    
    # Check for free retreat effects
    free_effects = self.effects_manager.query_effects(
        game,
        effect_type="free_retreat",
        affecting_card_id=pokemon.instance_id,
    )
    if free_effects:
        cost = 0
    
    return max(0, cost)

4. Lifecycle Hooks

def on_pokemon_enters_play(self, game: GameState, pokemon: CardInstance, player_id: str):
    """Called when a Pokemon enters the battlefield."""
    card_def = game.get_card_definition(pokemon.definition_id)
    if not card_def or not card_def.abilities:
        return
    
    for ability in card_def.abilities:
        if ability.effect_id and ability.effect_id.startswith("aura_"):
            self._register_ability_aura(game, pokemon, player_id, ability)


def on_pokemon_leaves_play(self, game: GameState, card_id: str):
    """Called when a Pokemon leaves the battlefield."""
    removed = self.effects_manager.remove_effects_by_source(game, card_id)
    if removed > 0:
        game.action_log.append({
            "type": "effects_removed",
            "source_card_id": card_id,
            "count": removed,
        })


def on_turn_end(self, game: GameState):
    """Called at the end of each turn."""
    self.effects_manager.remove_expired_effects(game)

Effect Categories

Based on research of Genetic Apex (A1) and Mythical Island (A1a) card sets (~372 cards), the following effect patterns were identified:

Ability Effects

Effect Type Example Card Description
energy_acceleration Gardevoir (Psy Shadow) Attach Energy from Energy Zone to Active
energy_multiplier Serperior (Jungle Totem) Each Grass Energy provides 2 Grass Energy
bench_snipe Greninja (Water Shuriken) Deal damage to any opponent's Pokemon
gust_basic Victreebel (Fragrance Trap) Switch in opponent's Benched Basic
auto_status Weezing (Gas Leak) Automatically inflict status condition
coinflip_status Hypno (Sleep Pendulum) Coin flip to inflict status
block_evolution Aerodactyl ex (Primeval Law) Opponent can't evolve Active

Attack Effects

Effect Type Example Card Description
damage_per_bench_own Pikachu ex (Circle Circuit) +X damage per own Benched Pokemon
damage_per_bench_opp Pidgeot ex (Scattering Cyclone) +X damage per opponent's Benched Pokemon
damage_per_energy_coinflip Celebi ex (Powerful Bloom) Flip coin per Energy, damage per heads
damage_if_extra_energy Blastoise ex (Hydro Bazooka) Bonus if extra Energy attached
damage_if_status Muk (Venoshock) Bonus if opponent is Poisoned
damage_if_ko_last_turn Marshadow (Revenge) Bonus if own Pokemon was KO'd
self_bench_damage Zapdos (Raging Thunder) Deal damage to own Bench
discard_energy_self Charizard ex (Crimson Storm) Discard Energy from self
discard_energy_self_all Raichu (Thunderbolt) Discard all Energy from self
discard_energy_random_all Gyarados ex (Rampaging Whirlpool) Discard random Energy from any Pokemon
inflict_sleep Vileplume (Soothing Scent) Opponent's Active is now Asleep
inflict_poison Various Opponent's Active is now Poisoned
prevent_retreat Arbok (Corner) Opponent can't retreat next turn
block_supporters Gengar (Bother) Opponent can't play Supporters next turn
reduce_damage_next_turn Mr. Mime (Barrier Attack) This Pokemon takes -X damage next turn
heal_self Venusaur ex (Giant Bloom) Heal damage from this Pokemon
reveal_hand Mew (Psy Report) Opponent reveals their hand
copy_attack Mew ex (Genome Hacking) Use opponent's Active's attack

Example Implementations

Example 1: Serperior's "Jungle Totem" (Energy Multiplier)

Card Definition:

CardDefinition(
    id="a1a-006-serperior",
    name="Serperior",
    card_type=CardType.POKEMON,
    stage=PokemonStage.STAGE_2,
    evolves_from="Servine",
    hp=110,
    pokemon_type=EnergyType.GRASS,
    abilities=[
        Ability(
            name="Jungle Totem",
            effect_id="aura_energy_multiplier",
            effect_params={
                "energy_type": "grass",
                "multiplier": 2,
            },
            effect_description="Each Grass Energy attached to your Grass Pokemon provides 2 Grass Energy. This effect doesn't stack.",
            uses_per_turn=None,  # Passive
        ),
    ],
    # ...
)

Effect Handler:

@effect_handler("aura_energy_multiplier")
def handle_aura_energy_multiplier(ctx: EffectContext) -> EffectResult:
    """Register an energy multiplier aura."""
    energy_type = ctx.get_str_param("energy_type")
    multiplier = ctx.get_int_param("multiplier", 2)
    
    ctx.game.effects_manager.register_effect(
        game=ctx.game,
        effect_type="energy_multiplier",
        source_type="ability",
        source_card_id=ctx.source_card_id,
        source_player_id=ctx.source_player_id,
        trigger=EffectTrigger.ON_ENERGY_COUNT,
        scope=EffectScope.TEAM_ALL,
        params={
            "energy_type": energy_type,
            "multiplier": multiplier,
        },
        stacking_mode=StackingMode.NO_STACK,  # "This effect doesn't stack"
        stacking_key=f"energy_multiplier_{energy_type}",
    )
    
    return EffectResult.success_result(
        f"Jungle Totem: {energy_type} energy provides x{multiplier}",
    )

Example 2: Greninja's "Water Shuriken" (Bench Snipe Ability)

Card Definition:

CardDefinition(
    id="a1-089-greninja",
    name="Greninja",
    card_type=CardType.POKEMON,
    stage=PokemonStage.STAGE_2,
    hp=120,
    pokemon_type=EnergyType.WATER,
    abilities=[
        Ability(
            name="Water Shuriken",
            effect_id="deal_damage_any",
            effect_params={
                "damage": 20,
                "target": "opponent_any",
            },
            effect_description="Once during your turn, you may do 20 damage to 1 of your opponent's Pokemon.",
            uses_per_turn=1,
        ),
    ],
    # ...
)

Effect Handler:

@effect_handler("deal_damage_any")
def handle_deal_damage_any(ctx: EffectContext) -> EffectResult:
    """Deal damage to any of opponent's Pokemon (requires target selection)."""
    damage = ctx.get_int_param("damage", 0)
    target_id = ctx.target_card_id  # Selected by player
    
    if not target_id:
        return EffectResult.failure_result("No target selected")
    
    # Find target Pokemon
    opponent = ctx.game.get_opponent(ctx.source_player_id)
    target = opponent.find_pokemon_in_play(target_id)
    
    if not target:
        return EffectResult.failure_result("Target not found")
    
    target.damage += damage
    
    return EffectResult.success_result(
        f"Water Shuriken dealt {damage} damage to {target_id}",
    )

Example 3: Mr. Mime's "Barrier Attack" (Temporary Damage Reduction)

Attack Definition:

Attack(
    name="Barrier Attack",
    cost=[EnergyType.PSYCHIC],
    damage=30,
    effect_id="reduce_damage_next_turn",
    effect_params={"reduction": 20},
    effect_description="During your opponent's next turn, this Pokemon takes -20 damage from attacks.",
)

Effect Handler:

@effect_handler("reduce_damage_next_turn")
def handle_reduce_damage_next_turn(ctx: EffectContext) -> EffectResult:
    """Register a temporary damage reduction effect that lasts until end of opponent's next turn."""
    reduction = ctx.get_int_param("reduction", 0)
    
    ctx.game.effects_manager.register_effect(
        game=ctx.game,
        effect_type="damage_reduction",
        source_type="attack",
        source_card_id=ctx.source_card_id,
        source_player_id=ctx.source_player_id,
        trigger=EffectTrigger.ON_DAMAGE_CALC,
        scope=EffectScope.SELF,
        params={"amount": reduction},
        stacking_mode=StackingMode.ADDITIVE,
        duration=1,  # Expires after opponent's turn
    )
    
    return EffectResult.success_result(
        f"This Pokemon takes -{reduction} damage until end of opponent's next turn",
    )

Example 4: Victreebel's "Fragrance Trap" (Gust Effect)

Ability Definition:

Ability(
    name="Fragrance Trap",
    effect_id="gust_opponent_basic",
    effect_params={},
    effect_description="If this Pokemon is in the Active Spot, once during your turn, you may switch in 1 of your opponent's Benched Basic Pokemon to the Active Spot.",
    uses_per_turn=1,
)

Effect Handler:

@effect_handler("gust_opponent_basic")
def handle_gust_opponent_basic(ctx: EffectContext) -> EffectResult:
    """Switch in one of opponent's Benched Basic Pokemon."""
    # Verify source is in Active Spot
    player = ctx.game.players[ctx.source_player_id]
    active = player.get_active_pokemon()
    
    if not active or active.instance_id != ctx.source_card_id:
        return EffectResult.failure_result("This Pokemon must be in the Active Spot")
    
    target_id = ctx.target_card_id
    if not target_id:
        return EffectResult.failure_result("No target selected")
    
    # Find target on opponent's bench
    opponent = ctx.game.get_opponent(ctx.source_player_id)
    target = opponent.bench.remove(target_id)
    
    if not target:
        return EffectResult.failure_result("Target not found on opponent's bench")
    
    # Verify it's a Basic Pokemon
    target_def = ctx.game.get_card_definition(target.definition_id)
    if target_def.stage != PokemonStage.BASIC:
        # Return to bench and fail
        opponent.bench.add(target)
        return EffectResult.failure_result("Target must be a Basic Pokemon")
    
    # Swap opponent's Active with target
    old_active = opponent.get_active_pokemon()
    if old_active:
        opponent.active.remove(old_active.instance_id)
        opponent.bench.add(old_active)
    
    opponent.active.add(target)
    
    return EffectResult.success_result(
        f"Switched {target_def.name} to opponent's Active Spot",
    )

Suppression System

Some abilities can disable other abilities. This is handled via the suppressed flag:

@effect_handler("suppress_abilities")
def handle_suppress_abilities(ctx: EffectContext) -> EffectResult:
    """Suppress all ability-based effects."""
    count = 0
    for effect in ctx.game.active_effects:
        if effect.source_type == "ability" and not effect.suppressed:
            effect.suppressed = True
            count += 1
    
    # Register suppression tracker
    ctx.game.effects_manager.register_effect(
        game=ctx.game,
        effect_type="ability_suppression",
        source_type="ability",
        source_card_id=ctx.source_card_id,
        source_player_id=ctx.source_player_id,
        trigger=EffectTrigger.CONTINUOUS,
        scope=EffectScope.GLOBAL,
        params={},
    )
    
    return EffectResult.success_result(f"Suppressed {count} ability effects")

When the suppressor leaves play, unsuppress all ability effects:

def on_suppression_ends(game: GameState, suppressor_card_id: str):
    """Called when an ability suppressor leaves play."""
    for effect in game.active_effects:
        if effect.source_type == "ability":
            effect.suppressed = False

File Structure

backend/app/core/
├── effects/
│   ├── __init__.py
│   ├── base.py              # EffectContext, EffectResult (existing)
│   ├── registry.py          # Effect handler registry (existing)
│   ├── handlers.py          # Built-in effect handlers (existing)
│   └── active_effects.py    # NEW: ActiveEffect, ActiveEffectsManager
├── engine.py                # Add effects_manager, integrate hooks
├── models/
│   └── game_state.py        # Add active_effects field
└── turn_manager.py          # Add lifecycle hooks

Migration Path

  1. Phase 1: Add ActiveEffect model and ActiveEffectsManager (no integration)
  2. Phase 2: Add active_effects field to GameState
  3. Phase 3: Integrate into damage calculation
  4. Phase 4: Add lifecycle hooks (enter/leave play, turn boundaries)
  5. Phase 5: Implement initial ability handlers
  6. Phase 6: Integrate into energy counting, retreat cost, etc.

Post-Launch TODO: Generic Modifier System

Priority: Low - Implement when we encounter abilities that don't fit the named effect types.

For maximum flexibility with future card sets, a more generic modifier system could supplement the named effect types:

class GenericModifier(BaseModel):
    """A generic numerical modifier for edge cases."""
    target: str           # "damage.taken", "energy.grass.count", "attack.cost.colorless"
    operation: str        # "add", "multiply", "set", "min", "max"
    value: int | float
    condition: str | None # Optional DSL: "attacker.type == fire"

When to use:

  • When a new card's effect doesn't fit existing named types
  • If custom_modifier is used frequently for similar patterns, promote to a named type

Example:

ActiveEffect(
    effect_type="custom_modifier",
    params={
        "target": "damage.taken",
        "operation": "add",
        "value": -20,
        "condition": "attacker.type == fire",
        "description": "Reduces damage from Fire attacks by 20",
    },
)

Open Questions

  1. Stacking limits: Should there be a maximum total from stacked effects?
  2. Effect ordering: When multiple effect types apply, is order significant?
  3. Visibility: Should opponents see active effects, or only their own team's?
  4. Stadium effects: Use this system, or keep as special case?
  5. Attack effects duration: "During opponent's next turn" - when exactly does it expire?

References