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
This commit is contained in:
parent
72bd1102df
commit
3ed67ea16b
195
backend/app/core/README.md
Normal file
195
backend/app/core/README.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# Core Game Engine Module
|
||||||
|
|
||||||
|
The `app.core` module contains the platform-independent game engine for Mantimon TCG. This module is designed to be **completely decoupled** from network and database concerns to enable both online multiplayer and a future offline/standalone version.
|
||||||
|
|
||||||
|
## Architecture Principle
|
||||||
|
|
||||||
|
> **The core engine should remain forkable as a standalone offline game.**
|
||||||
|
|
||||||
|
See [ARCHITECTURE.md](/docs/ARCHITECTURE.md#offline-standalone-fork) for details on this design goal.
|
||||||
|
|
||||||
|
### Import Boundaries
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ALLOWED in app/core/
|
||||||
|
from app.core.models import CardDefinition, GameState
|
||||||
|
from app.core.config import RulesConfig
|
||||||
|
from app.core.rng import RandomProvider
|
||||||
|
|
||||||
|
# FORBIDDEN in app/core/
|
||||||
|
from app.services import CardService # NO - DB dependency
|
||||||
|
from app.api.deps import get_current_user # NO - Auth dependency
|
||||||
|
from sqlalchemy import Session # NO - DB dependency
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submodules
|
||||||
|
|
||||||
|
| Directory/File | Description |
|
||||||
|
|----------------|-------------|
|
||||||
|
| `models/` | Pydantic models for cards, game state, actions |
|
||||||
|
| `effects/` | Effect system for abilities and attacks |
|
||||||
|
| `engine.py` | `GameEngine` - main orchestrator |
|
||||||
|
| `config.py` | `RulesConfig` and sub-configs |
|
||||||
|
| `turn_manager.py` | Turn/phase state machine |
|
||||||
|
| `rules_validator.py` | Action validation |
|
||||||
|
| `rng.py` | Random number generation |
|
||||||
|
| `visibility.py` | Hidden information filtering |
|
||||||
|
| `win_conditions.py` | Win/loss detection |
|
||||||
|
| `enums.py` | Shared enumerations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Classes
|
||||||
|
|
||||||
|
### GameEngine (`engine.py`)
|
||||||
|
|
||||||
|
The primary public API for all game operations.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core import GameEngine, RulesConfig
|
||||||
|
|
||||||
|
engine = GameEngine(rules=RulesConfig())
|
||||||
|
result = engine.create_game(
|
||||||
|
player_ids=["p1", "p2"],
|
||||||
|
decks={"p1": deck1, "p2": deck2},
|
||||||
|
card_registry=registry,
|
||||||
|
)
|
||||||
|
game = result.game
|
||||||
|
|
||||||
|
# Execute actions
|
||||||
|
action_result = await engine.execute_action(game, "p1", AttackAction(attack_index=0))
|
||||||
|
```
|
||||||
|
|
||||||
|
### RulesConfig (`config.py`)
|
||||||
|
|
||||||
|
Configures all game rules - deck sizes, prize counts, per-turn limits, etc.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core import RulesConfig
|
||||||
|
|
||||||
|
rules = RulesConfig(
|
||||||
|
deck=DeckConfig(min_size=60, energy_deck_enabled=False),
|
||||||
|
prizes=PrizeConfig(count=6),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameState (`models/game_state.py`)
|
||||||
|
|
||||||
|
Complete state of a game in progress. Self-contained and serializable.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.models import GameState
|
||||||
|
|
||||||
|
# Access player zones
|
||||||
|
player = game.players["player1"]
|
||||||
|
active = player.get_active_pokemon()
|
||||||
|
hand_size = len(player.hand)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TurnManager (`turn_manager.py`)
|
||||||
|
|
||||||
|
Handles turn phases and transitions.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core import TurnManager
|
||||||
|
|
||||||
|
manager = TurnManager()
|
||||||
|
manager.advance_phase(game) # DRAW -> MAIN -> ATTACK -> END
|
||||||
|
manager.advance_turn(game) # Switch to next player
|
||||||
|
```
|
||||||
|
|
||||||
|
### RandomProvider (`rng.py`)
|
||||||
|
|
||||||
|
Protocol for random operations. Supports seeded RNG for testing.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core import create_rng, SeededRandom
|
||||||
|
|
||||||
|
# Production: cryptographically secure
|
||||||
|
rng = create_rng()
|
||||||
|
|
||||||
|
# Testing: deterministic
|
||||||
|
rng = SeededRandom(seed=42)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enumerations (`enums.py`)
|
||||||
|
|
||||||
|
| Enum | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `CardType` | `POKEMON`, `TRAINER`, `ENERGY` |
|
||||||
|
| `PokemonStage` | `BASIC`, `STAGE_1`, `STAGE_2` |
|
||||||
|
| `EnergyType` | `FIRE`, `WATER`, `GRASS`, `LIGHTNING`, etc. |
|
||||||
|
| `TrainerType` | `ITEM`, `SUPPORTER`, `STADIUM`, `TOOL` |
|
||||||
|
| `StatusCondition` | `POISONED`, `BURNED`, `ASLEEP`, `PARALYZED`, `CONFUSED` |
|
||||||
|
| `TurnPhase` | `SETUP`, `DRAW`, `MAIN`, `ATTACK`, `END` |
|
||||||
|
| `GameEndReason` | `PRIZES`, `DECK_OUT`, `NO_POKEMON`, `RESIGNATION`, etc. |
|
||||||
|
| `ModifierMode` | `ADDITIVE`, `MULTIPLICATIVE` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Action Types (`models/actions.py`)
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `AttackAction` | Use an attack |
|
||||||
|
| `AttachEnergyAction` | Attach energy to Pokemon |
|
||||||
|
| `PlayPokemonAction` | Play Basic to bench/active |
|
||||||
|
| `EvolvePokemonAction` | Evolve a Pokemon |
|
||||||
|
| `PlayTrainerAction` | Play a Trainer card |
|
||||||
|
| `UseAbilityAction` | Use a Pokemon's ability |
|
||||||
|
| `RetreatAction` | Retreat active Pokemon |
|
||||||
|
| `SelectActiveAction` | Choose new active (after KO) |
|
||||||
|
| `PassAction` | End turn without attacking |
|
||||||
|
| `ResignAction` | Forfeit the game |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visibility System (`visibility.py`)
|
||||||
|
|
||||||
|
Filters game state to hide information opponents shouldn't see.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core import get_visible_state, get_spectator_state
|
||||||
|
|
||||||
|
# Player sees own hand, opponent sees card count
|
||||||
|
visible = get_visible_state(game, "player1")
|
||||||
|
|
||||||
|
# Spectator sees neither hand
|
||||||
|
spectator_view = get_spectator_state(game)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Effects System (`effects/`)
|
||||||
|
|
||||||
|
Handles card abilities and attack effects. See [effects/README.md](./effects/README.md).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.effects import resolve_effect, EffectContext
|
||||||
|
|
||||||
|
result = resolve_effect("deal_damage", ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && uv run pytest tests/core/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
All core module tests are in `tests/core/` with ~800+ tests at 97% coverage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [/docs/ARCHITECTURE.md](/docs/ARCHITECTURE.md) - System architecture
|
||||||
|
- [/docs/GAME_RULES.md](/docs/GAME_RULES.md) - Game rules
|
||||||
|
- [/docs/ACTIVE_EFFECTS_DESIGN.md](/docs/ACTIVE_EFFECTS_DESIGN.md) - Effects system design
|
||||||
|
- [effects/README.md](./effects/README.md) - Effects module
|
||||||
|
- [models/README.md](./models/README.md) - Models module
|
||||||
162
backend/app/core/effects/README.md
Normal file
162
backend/app/core/effects/README.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# Effects Module
|
||||||
|
|
||||||
|
The effects module provides the card effect system for Mantimon TCG, handling abilities, attack effects, and persistent game modifiers.
|
||||||
|
|
||||||
|
## Design Document
|
||||||
|
|
||||||
|
For the complete system design including the Active Effects registry, see:
|
||||||
|
**[/docs/ACTIVE_EFFECTS_DESIGN.md](/docs/ACTIVE_EFFECTS_DESIGN.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `__init__.py` | Module exports (`EffectContext`, `EffectResult`, `resolve_effect`, etc.) |
|
||||||
|
| `base.py` | Core types: `EffectContext`, `EffectResult`, `EffectType` |
|
||||||
|
| `registry.py` | Effect handler registry and `@effect_handler` decorator |
|
||||||
|
| `handlers.py` | Built-in effect handlers for common card effects |
|
||||||
|
|
||||||
|
### Future Additions
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `active_effects.py` | `ActiveEffect` model and `ActiveEffectsManager` for persistent effects |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Registering an Effect Handler
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.effects.registry import effect_handler
|
||||||
|
|
||||||
|
@effect_handler("my_effect_id")
|
||||||
|
def handle_my_effect(ctx: EffectContext) -> EffectResult:
|
||||||
|
"""Handler for my_effect_id.
|
||||||
|
|
||||||
|
Called when a card with effect_id="my_effect_id" is used.
|
||||||
|
"""
|
||||||
|
# Extract parameters
|
||||||
|
amount = ctx.get_int_param("amount", 0)
|
||||||
|
|
||||||
|
# Apply effect to game state
|
||||||
|
# ...
|
||||||
|
|
||||||
|
return EffectResult.success_result("Effect applied")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolving an Effect
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.effects import EffectContext, resolve_effect
|
||||||
|
|
||||||
|
ctx = EffectContext(
|
||||||
|
game=game_state,
|
||||||
|
source_player_id="player1",
|
||||||
|
rng=rng,
|
||||||
|
source_card_id="pikachu-001",
|
||||||
|
target_card_id="bulbasaur-001",
|
||||||
|
params={"damage": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resolve_effect("deal_damage", ctx)
|
||||||
|
if result.success:
|
||||||
|
print(result.message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing Available Effects
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.effects import list_effects
|
||||||
|
|
||||||
|
for effect_id in list_effects():
|
||||||
|
print(effect_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in Effect Handlers
|
||||||
|
|
||||||
|
### Damage Effects
|
||||||
|
|
||||||
|
| Effect ID | Description | Parameters |
|
||||||
|
|-----------|-------------|------------|
|
||||||
|
| `deal_damage` | Raw damage (poison, burn, recoil) | `damage`, `target` |
|
||||||
|
| `attack_damage` | Combat damage with W/R | `damage` |
|
||||||
|
| `coin_flip_damage` | Damage based on coin flips | `base_damage`, `bonus_on_heads` |
|
||||||
|
| `bench_damage` | Damage to benched Pokemon | `damage`, `target_count` |
|
||||||
|
|
||||||
|
### Healing & Status
|
||||||
|
|
||||||
|
| Effect ID | Description | Parameters |
|
||||||
|
|-----------|-------------|------------|
|
||||||
|
| `heal` | Heal damage from Pokemon | `amount`, `target` |
|
||||||
|
| `apply_status` | Apply status condition | `status` |
|
||||||
|
| `remove_status` | Remove status condition | `status` |
|
||||||
|
|
||||||
|
### Card Movement
|
||||||
|
|
||||||
|
| Effect ID | Description | Parameters |
|
||||||
|
|-----------|-------------|------------|
|
||||||
|
| `draw_cards` | Draw from deck | `count`, `discard_hand` |
|
||||||
|
| `discard_from_hand` | Discard cards | `count` |
|
||||||
|
| `shuffle_deck` | Shuffle deck | - |
|
||||||
|
|
||||||
|
### Energy & Modifiers
|
||||||
|
|
||||||
|
| Effect ID | Description | Parameters |
|
||||||
|
|-----------|-------------|------------|
|
||||||
|
| `discard_energy` | Discard energy from Pokemon | `count`, `energy_type` |
|
||||||
|
| `modify_hp` | Change HP modifier | `amount` |
|
||||||
|
| `modify_retreat_cost` | Change retreat cost | `amount` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EffectContext
|
||||||
|
|
||||||
|
The `EffectContext` provides all information needed by effect handlers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EffectContext:
|
||||||
|
game: GameState # Current game state
|
||||||
|
source_player_id: str # Player using the effect
|
||||||
|
rng: RandomProvider # For coin flips, random selection
|
||||||
|
source_card_id: str # Card providing the effect
|
||||||
|
target_card_id: str # Target of the effect (if any)
|
||||||
|
params: dict # Effect-specific parameters
|
||||||
|
|
||||||
|
# Helper methods
|
||||||
|
def get_int_param(key: str, default: int = 0) -> int
|
||||||
|
def get_str_param(key: str, default: str = "") -> str
|
||||||
|
def get_param(key: str, default: Any = None) -> Any
|
||||||
|
def get_source_pokemon() -> CardInstance | None
|
||||||
|
def get_target_pokemon() -> CardInstance | None
|
||||||
|
```
|
||||||
|
|
||||||
|
## EffectResult
|
||||||
|
|
||||||
|
Effect handlers return an `EffectResult`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EffectResult:
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
effect_type: EffectType
|
||||||
|
details: dict
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success_result(cls, message: str, ...) -> EffectResult
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def failure_result(cls, message: str) -> EffectResult
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [ACTIVE_EFFECTS_DESIGN.md](/docs/ACTIVE_EFFECTS_DESIGN.md) - Full design document
|
||||||
|
- [handlers.py](./handlers.py) - Implementation of built-in handlers
|
||||||
278
backend/app/core/models/README.md
Normal file
278
backend/app/core/models/README.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# Models Module
|
||||||
|
|
||||||
|
Pydantic models for game state, card definitions, and player actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `__init__.py` | Module exports |
|
||||||
|
| `card.py` | Card-related models |
|
||||||
|
| `game_state.py` | Game state and player state |
|
||||||
|
| `actions.py` | Player action types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card Models (`card.py`)
|
||||||
|
|
||||||
|
### CardDefinition
|
||||||
|
|
||||||
|
Immutable template defining a card's properties. Stored in the card registry.
|
||||||
|
|
||||||
|
```python
|
||||||
|
CardDefinition(
|
||||||
|
id="pikachu-001",
|
||||||
|
name="Pikachu",
|
||||||
|
card_type=CardType.POKEMON,
|
||||||
|
stage=PokemonStage.BASIC,
|
||||||
|
hp=60,
|
||||||
|
pokemon_type=EnergyType.LIGHTNING,
|
||||||
|
attacks=[
|
||||||
|
Attack(name="Thunder Shock", damage=20, cost=[EnergyType.LIGHTNING]),
|
||||||
|
],
|
||||||
|
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, value=20),
|
||||||
|
retreat_cost=1,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CardInstance
|
||||||
|
|
||||||
|
A specific card in play, referencing a `CardDefinition`. Tracks runtime state.
|
||||||
|
|
||||||
|
```python
|
||||||
|
CardInstance(
|
||||||
|
instance_id="p1-pikachu-0", # Unique per-game
|
||||||
|
definition_id="pikachu-001", # References CardDefinition
|
||||||
|
damage=30, # Current damage
|
||||||
|
attached_energy=[...], # Energy cards attached
|
||||||
|
status_conditions=[StatusCondition.PARALYZED],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attack
|
||||||
|
|
||||||
|
A Pokemon's attack with cost, damage, and optional effect.
|
||||||
|
|
||||||
|
```python
|
||||||
|
Attack(
|
||||||
|
name="Crimson Storm",
|
||||||
|
cost=[EnergyType.FIRE, EnergyType.FIRE, EnergyType.COLORLESS],
|
||||||
|
damage=200,
|
||||||
|
effect_id="discard_energy",
|
||||||
|
effect_params={"count": 2},
|
||||||
|
effect_description="Discard 2 Energy from this Pokemon.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ability
|
||||||
|
|
||||||
|
A Pokemon's special ability.
|
||||||
|
|
||||||
|
```python
|
||||||
|
Ability(
|
||||||
|
name="Psy Shadow",
|
||||||
|
effect_id="energy_acceleration",
|
||||||
|
effect_params={"energy_type": "psychic", "target": "active"},
|
||||||
|
effect_description="Attach Psychic Energy from Energy Zone to Active.",
|
||||||
|
uses_per_turn=1,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### WeaknessResistance
|
||||||
|
|
||||||
|
Weakness or resistance to an energy type.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Additive weakness (+20)
|
||||||
|
WeaknessResistance(energy_type=EnergyType.FIRE, mode=ModifierMode.ADDITIVE, value=20)
|
||||||
|
|
||||||
|
# Multiplicative weakness (x2)
|
||||||
|
WeaknessResistance(energy_type=EnergyType.FIRE, mode=ModifierMode.MULTIPLICATIVE, value=2)
|
||||||
|
|
||||||
|
# Resistance (-30)
|
||||||
|
WeaknessResistance(energy_type=EnergyType.WATER, mode=ModifierMode.ADDITIVE, value=-30)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game State Models (`game_state.py`)
|
||||||
|
|
||||||
|
### GameState
|
||||||
|
|
||||||
|
Complete state of a game. Self-contained with embedded card registry.
|
||||||
|
|
||||||
|
```python
|
||||||
|
GameState(
|
||||||
|
game_id="match-123",
|
||||||
|
rules=RulesConfig(),
|
||||||
|
card_registry={...},
|
||||||
|
players={"p1": PlayerState(...), "p2": PlayerState(...)},
|
||||||
|
current_player_id="p1",
|
||||||
|
turn_number=3,
|
||||||
|
phase=TurnPhase.MAIN,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `get_current_player()` - Current player's state
|
||||||
|
- `get_opponent(player_id)` - Opponent's state
|
||||||
|
- `get_card_definition(card_id)` - Lookup card definition
|
||||||
|
- `advance_turn()` - Move to next player's turn
|
||||||
|
|
||||||
|
### PlayerState
|
||||||
|
|
||||||
|
All zones and state for a single player.
|
||||||
|
|
||||||
|
```python
|
||||||
|
player = game.players["p1"]
|
||||||
|
|
||||||
|
# Zones (all are Zone objects)
|
||||||
|
player.deck # Draw pile
|
||||||
|
player.hand # Cards in hand
|
||||||
|
player.active # Active Pokemon (0-1 cards)
|
||||||
|
player.bench # Benched Pokemon
|
||||||
|
player.discard # Discard pile
|
||||||
|
player.prizes # Prize cards
|
||||||
|
player.energy_deck # Energy deck (Pocket-style)
|
||||||
|
player.energy_zone # Available energy this turn
|
||||||
|
|
||||||
|
# State tracking
|
||||||
|
player.score # Points scored
|
||||||
|
player.energy_attachments_this_turn
|
||||||
|
player.supporters_played_this_turn
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `get_active_pokemon()` - Returns active Pokemon or None
|
||||||
|
- `get_all_pokemon_in_play()` - Active + bench
|
||||||
|
- `can_attach_energy(rules)` - Check turn limits
|
||||||
|
- `reset_turn_state()` - Clear per-turn counters
|
||||||
|
|
||||||
|
### Zone
|
||||||
|
|
||||||
|
A collection of cards (deck, hand, bench, etc.).
|
||||||
|
|
||||||
|
```python
|
||||||
|
zone = player.hand
|
||||||
|
|
||||||
|
len(zone) # Card count
|
||||||
|
"card-id" in zone # Check if card present
|
||||||
|
zone.add(card) # Add to end
|
||||||
|
zone.add_to_top(card) # Add to top (for deck)
|
||||||
|
zone.remove("card-id") # Remove and return card
|
||||||
|
zone.draw() # Remove and return from top
|
||||||
|
zone.shuffle(rng) # Randomize order
|
||||||
|
zone.get("card-id") # Get without removing
|
||||||
|
zone.clear() # Remove all cards
|
||||||
|
```
|
||||||
|
|
||||||
|
### ForcedAction
|
||||||
|
|
||||||
|
Represents a required action (e.g., "select new active after KO").
|
||||||
|
|
||||||
|
```python
|
||||||
|
ForcedAction(
|
||||||
|
player_id="p1",
|
||||||
|
action_type="select_active",
|
||||||
|
reason="Your active Pokemon was knocked out",
|
||||||
|
params={"from_zones": ["bench"]},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Action Models (`actions.py`)
|
||||||
|
|
||||||
|
All actions implement a common interface with `type` discriminator field.
|
||||||
|
|
||||||
|
### Attack Actions
|
||||||
|
|
||||||
|
```python
|
||||||
|
AttackAction(attack_index=0) # Use first attack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Energy Actions
|
||||||
|
|
||||||
|
```python
|
||||||
|
AttachEnergyAction(
|
||||||
|
energy_card_id="p1-lightning-0",
|
||||||
|
target_pokemon_id="p1-pikachu-0",
|
||||||
|
from_energy_zone=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pokemon Actions
|
||||||
|
|
||||||
|
```python
|
||||||
|
PlayPokemonAction(card_id="p1-bulbasaur-0", to_active=False)
|
||||||
|
EvolvePokemonAction(card_id="p1-ivysaur-0", target_pokemon_id="p1-bulbasaur-0")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trainer Actions
|
||||||
|
|
||||||
|
```python
|
||||||
|
PlayTrainerAction(card_instance_id="p1-potion-0", targets=["p1-pikachu-0"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ability Actions
|
||||||
|
|
||||||
|
```python
|
||||||
|
UseAbilityAction(pokemon_id="p1-greninja-0", ability_index=0, targets=["p2-charmander-0"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Movement Actions
|
||||||
|
|
||||||
|
```python
|
||||||
|
RetreatAction(new_active_id="p1-squirtle-0", energy_to_discard=["p1-water-0"])
|
||||||
|
SelectActiveAction(new_active_id="p1-squirtle-0") # After KO
|
||||||
|
```
|
||||||
|
|
||||||
|
### Turn Control
|
||||||
|
|
||||||
|
```python
|
||||||
|
PassAction() # End turn without attacking
|
||||||
|
ResignAction() # Forfeit the game
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Union Type
|
||||||
|
|
||||||
|
```python
|
||||||
|
Action = (
|
||||||
|
AttackAction | AttachEnergyAction | PlayPokemonAction |
|
||||||
|
EvolvePokemonAction | PlayTrainerAction | UseAbilityAction |
|
||||||
|
RetreatAction | SelectActiveAction | PassAction | ResignAction |
|
||||||
|
SelectPrizeAction
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
GameState
|
||||||
|
├── card_registry: dict[str, CardDefinition]
|
||||||
|
├── players: dict[str, PlayerState]
|
||||||
|
│ └── PlayerState
|
||||||
|
│ ├── deck: Zone[CardInstance]
|
||||||
|
│ ├── hand: Zone[CardInstance]
|
||||||
|
│ ├── active: Zone[CardInstance]
|
||||||
|
│ │ └── CardInstance
|
||||||
|
│ │ ├── definition_id -> CardDefinition
|
||||||
|
│ │ ├── attached_energy: list[CardInstance]
|
||||||
|
│ │ └── evolution_stage: list[CardInstance]
|
||||||
|
│ ├── bench: Zone[CardInstance]
|
||||||
|
│ ├── discard: Zone[CardInstance]
|
||||||
|
│ └── ...
|
||||||
|
├── stadium_in_play: CardInstance | None
|
||||||
|
└── forced_actions: list[ForcedAction]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [../README.md](../README.md) - Core module overview
|
||||||
|
- [/docs/ARCHITECTURE.md](/docs/ARCHITECTURE.md) - System architecture
|
||||||
@ -367,7 +367,7 @@ def create_card_definitions() -> dict[str, CardDefinition]:
|
|||||||
effect_description="Heal 10 damage from this Pokemon.",
|
effect_description="Heal 10 damage from this Pokemon.",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
weakness=WeaknessResistance(energy_type=EnergyType.LIGHTNING, mode=ModifierMode.ADDITIVE, value=20),
|
weakness=WeaknessResistance(energy_type=EnergyType.FIRE),
|
||||||
resistance=WeaknessResistance(energy_type=EnergyType.WATER, value=-30),
|
resistance=WeaknessResistance(energy_type=EnergyType.WATER, value=-30),
|
||||||
retreat_cost=2,
|
retreat_cost=2,
|
||||||
)
|
)
|
||||||
@ -935,7 +935,7 @@ if it has enough energy attached.
|
|||||||
opp_def = game.get_card_definition(opp_active.definition_id)
|
opp_def = game.get_card_definition(opp_active.definition_id)
|
||||||
max_hp = opp_def.hp + opp_active.hp_modifier if opp_def else 0
|
max_hp = opp_def.hp + opp_active.hp_modifier if opp_def else 0
|
||||||
print_result(
|
print_result(
|
||||||
f"Opponent's {opp_def.name if opp_def else 'Pokemon'}: {max_hp - opp_active.damage}/{max_hp} HP / CALCULATED HP: {opp_def.hp}"
|
f"Opponent's {opp_def.name if opp_def else 'Pokemon'}: {max_hp - opp_active.damage}/{max_hp}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(error(f"Attack failed: {result.message}"))
|
print(error(f"Attack failed: {result.message}"))
|
||||||
|
|||||||
691
docs/ACTIVE_EFFECTS_DESIGN.md
Normal file
691
docs/ACTIVE_EFFECTS_DESIGN.md
Normal file
@ -0,0 +1,691 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GameState(BaseModel):
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Active effects registry
|
||||||
|
active_effects: list[ActiveEffect] = Field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Operations
|
||||||
|
|
||||||
|
### ActiveEffectsManager
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:**
|
||||||
|
```python
|
||||||
|
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:**
|
||||||
|
```python
|
||||||
|
@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:**
|
||||||
|
```python
|
||||||
|
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:**
|
||||||
|
```python
|
||||||
|
@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:**
|
||||||
|
```python
|
||||||
|
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:**
|
||||||
|
```python
|
||||||
|
@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:**
|
||||||
|
```python
|
||||||
|
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:**
|
||||||
|
```python
|
||||||
|
@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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:**
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](/docs/ARCHITECTURE.md) - Core engine architecture
|
||||||
|
- [GAME_RULES.md](/docs/GAME_RULES.md) - Game rules and mechanics
|
||||||
|
- [backend/app/core/effects/README.md](/backend/app/core/effects/README.md) - Effects module index
|
||||||
Loading…
Reference in New Issue
Block a user