Implement energy/tools as CardInstance + evolution stack + devolve effect
Major refactor to properly track attached cards and evolution history: Model Changes (app/core/models/card.py): - Change attached_energy from list[str] to list[CardInstance] - Change attached_tools from list[str] to list[CardInstance] - Add cards_underneath field for evolution stack tracking - Update attach_energy/detach_energy to work with CardInstance - Add attach_tool/detach_tool methods - Add get_all_attached_cards helper Engine Changes (app/core/engine.py): - _execute_attach_energy: Pass full CardInstance to attach_energy - _execute_evolve: Build evolution stack, transfer attachments, clear status - _execute_retreat: Detached energy goes to discard pile - Fix: Evolution now clears status conditions (Pokemon TCG standard) Game State (app/core/models/game_state.py): - find_card_instance now searches attached_energy, attached_tools, cards_underneath Turn Manager (app/core/turn_manager.py): - process_knockout: Discard all attached energy, tools, and evolution stack Effects (app/core/effects/handlers.py): - discard_energy: Find owner's discard pile and move detached energy there - NEW devolve effect: Remove evolution stages with configurable destination - Fix: Use EffectType.SPECIAL instead of non-existent EffectType.ZONE Rules Validator (app/core/rules_validator.py): - Update energy type checking to iterate CardInstance objects Tests: - Update existing tests for new CardInstance-based energy attachment - NEW test_evolution_stack.py with 28 comprehensive tests covering: - Evolution stack building (Basic -> Stage 1 -> Stage 2) - Energy/tool transfer and damage carryover on evolution - Devolve effect (single/multi stage, hand/discard destination, KO check) - Knockout processing with all attachments going to discard - find_card_instance for attached cards and evolution stack All 765 tests pass.
This commit is contained in:
parent
c3ab03c691
commit
2b8fac405f
257
backend/PROJECT_PLAN_ENERGY_EVOLUTION.md
Normal file
257
backend/PROJECT_PLAN_ENERGY_EVOLUTION.md
Normal file
@ -0,0 +1,257 @@
|
||||
# Project Plan: Energy, Tools, and Evolution Stack Refactor
|
||||
|
||||
**Created:** 2026-01-25
|
||||
**Updated:** 2026-01-25
|
||||
**Status:** COMPLETE (All phases done, 765 tests passing)
|
||||
**Priority:** Critical (Phase 1 fixes from SYSTEM_REVIEW.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Refactor the card attachment and evolution system to properly track:
|
||||
1. **Energy cards** as `CardInstance` objects attached to Pokemon (not just IDs)
|
||||
2. **Tool cards** as `CardInstance` objects attached to Pokemon (not just IDs)
|
||||
3. **Evolution stack** - previous evolution stages stored underneath the current Pokemon
|
||||
|
||||
This fixes critical bugs where energy cards "disappeared" and enables proper devolve mechanics.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| `attached_energy` type | `list[CardInstance]` (was `list[str]`) |
|
||||
| `attached_tools` type | `list[CardInstance]` (was `list[str]`) |
|
||||
| Evolution behavior | Previous stages stored in `cards_underneath` (was discarded) |
|
||||
| Energy on KO | Moves to owner's discard pile |
|
||||
| Tools on KO | Moves to owner's discard pile |
|
||||
| Evolution stack on KO | All cards in stack move to owner's discard |
|
||||
| Devolve mechanic | Effect only (not player action) |
|
||||
| Devolve stages | Configurable per effect (default 1) |
|
||||
| Devolve destination | Configurable: "hand" (default) or "discard" |
|
||||
| Devolve KO check | Immediate - if damage >= new HP, Pokemon is KO'd |
|
||||
| Energy/tools on devolve | Stay attached to the devolved Pokemon |
|
||||
| Damage on evolve/devolve | Carries over (enables sneaky KO strategies) |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Update CardInstance Model
|
||||
**File:** `app/core/models/card.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Change `attached_energy: list[str]` to `list["CardInstance"]`
|
||||
- [x] Change `attached_tools: list[str]` to `list["CardInstance"]`
|
||||
- [x] Add `cards_underneath: list["CardInstance"]` field
|
||||
- [x] Update `attach_energy()` method signature to accept `CardInstance`
|
||||
- [x] Update `detach_energy()` to return `CardInstance | None`
|
||||
- [x] Add `attach_tool()` method
|
||||
- [x] Add `detach_tool()` method returning `CardInstance | None`
|
||||
- [x] Update docstrings for all affected fields/methods
|
||||
- [x] Add `CardInstance.model_rebuild()` for self-referential types
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Revert Incorrect Fix
|
||||
**File:** `app/core/engine.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Remove `player.discard.add(energy_card)` from `_execute_attach_energy`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Update Energy Attachment
|
||||
**File:** `app/core/engine.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Change `target.attach_energy(energy_card.instance_id)` to `target.attach_energy(energy_card)`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Update Evolution Execution
|
||||
**File:** `app/core/engine.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Transfer `attached_energy` list (not copy IDs)
|
||||
- [x] Transfer `attached_tools` list
|
||||
- [x] Transfer `damage` and `status_conditions`
|
||||
- [x] Build `cards_underneath` stack (copy existing + append target)
|
||||
- [x] Clear target's attached lists after transfer
|
||||
- [x] Remove `player.discard.add(target)` - target stays in stack
|
||||
- [x] Update `turn_played` tracking on evo_card
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Update Retreat Execution
|
||||
**File:** `app/core/engine.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Call `detach_energy()` which returns `CardInstance`
|
||||
- [x] Add returned energy to `player.discard`
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Update find_card_instance
|
||||
**File:** `app/core/models/game_state.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Search `pokemon.attached_energy` for each Pokemon in play
|
||||
- [x] Search `pokemon.attached_tools` for each Pokemon in play
|
||||
- [x] Search `pokemon.cards_underneath` for each Pokemon in play
|
||||
- [x] Return appropriate zone names: `"attached_energy"`, `"attached_tools"`, `"cards_underneath"`
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Update Knockout Processing
|
||||
**File:** `app/core/turn_manager.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Add TODO comment for future hook point (pre_knockout_discard event)
|
||||
- [x] Discard all `attached_energy` to owner's discard
|
||||
- [x] Discard all `attached_tools` to owner's discard
|
||||
- [x] Discard all `cards_underneath` to owner's discard
|
||||
- [x] Clear lists before discarding Pokemon
|
||||
- [x] Apply to both active and bench knockout sections
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Update discard_energy Effect Handler
|
||||
**File:** `app/core/effects/handlers.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Find owner of target Pokemon (for discard pile access)
|
||||
- [x] Update to work with `CardInstance` objects
|
||||
- [x] Call `detach_energy()` and add result to owner's discard
|
||||
- [x] Update docstring
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Add devolve Effect Handler
|
||||
**File:** `app/core/effects/handlers.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Create `@effect_handler("devolve")` decorator
|
||||
- [x] Accept `stages` param (int, default 1)
|
||||
- [x] Accept `destination` param ("hand" default, or "discard")
|
||||
- [x] Validate target is evolved (has cards_underneath)
|
||||
- [x] Find owner and zone of target Pokemon
|
||||
- [x] Implement stage removal loop:
|
||||
- Pop previous from cards_underneath
|
||||
- Transfer all state (energy, tools, remaining stack, damage, status, modifiers)
|
||||
- Create CardInstance for removed evolution
|
||||
- Send to destination (hand or discard)
|
||||
- Swap in zone
|
||||
- [x] Check for KO after devolve (damage >= new HP)
|
||||
- [x] Return result with knockout flag if applicable
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: Update Rules Validator
|
||||
**File:** `app/core/rules_validator.py`
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Update `_get_attached_energy_types()` to iterate CardInstance objects
|
||||
- [x] Update retreat energy validation to check instance_id in CardInstance list
|
||||
|
||||
---
|
||||
|
||||
### Phase 11: Update Existing Tests
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
Files updated:
|
||||
- [x] `tests/core/test_models/test_card.py` - attach/detach signatures, serialization
|
||||
- [x] `tests/core/test_engine.py` - energy attachment, evolution assertions
|
||||
- [x] `tests/core/test_effects/test_handlers.py` - discard_energy tests
|
||||
- [x] `tests/core/test_rules_validator.py` - energy requirement checks
|
||||
- [x] `tests/core/conftest.py` - game_in_main_phase and game_in_attack_phase fixtures
|
||||
|
||||
---
|
||||
|
||||
### Phase 12: Add New Tests
|
||||
**Status:** [x] COMPLETE
|
||||
**Test File:** `tests/core/test_evolution_stack.py` (28 tests)
|
||||
|
||||
Evolution Stack Tests:
|
||||
- [x] Basic → Stage 1 creates correct stack
|
||||
- [x] Stage 1 → Stage 2 preserves full stack
|
||||
- [x] Energy/tools transfer on evolution
|
||||
- [x] Damage carries over on evolution
|
||||
- [x] Status conditions clear on evolution (Pokemon TCG standard)
|
||||
|
||||
Devolve Effect Tests:
|
||||
- [x] Devolve Stage 2 → Stage 1 (stages=1)
|
||||
- [x] Devolve Stage 2 → Basic (stages=2)
|
||||
- [x] Devolve to hand (default destination)
|
||||
- [x] Devolve to discard
|
||||
- [x] Devolve triggers KO when damage >= new HP
|
||||
- [x] Cannot devolve Basic (failure)
|
||||
- [x] Energy/tools remain attached after devolve
|
||||
|
||||
Knockout Tests:
|
||||
- [x] Attached energy goes to discard on KO
|
||||
- [x] Attached tools go to discard on KO
|
||||
- [x] Evolution stack goes to discard on KO
|
||||
- [x] All attachments discard together on KO
|
||||
|
||||
find_card_instance Tests:
|
||||
- [x] Find attached energy by ID
|
||||
- [x] Find attached tool by ID
|
||||
- [x] Find card in evolution stack
|
||||
- [x] Find attached card on bench Pokemon
|
||||
- [x] Returns None for nonexistent card
|
||||
|
||||
CardInstance Method Tests:
|
||||
- [x] attach_energy adds CardInstance
|
||||
- [x] detach_energy removes and returns CardInstance
|
||||
- [x] detach_energy returns None for not attached
|
||||
- [x] attach_tool adds CardInstance
|
||||
- [x] detach_tool removes and returns CardInstance
|
||||
- [x] get_all_attached_cards returns energy and tools
|
||||
- [x] Multiple energy attachment works
|
||||
|
||||
---
|
||||
|
||||
### Phase 13: Final Verification
|
||||
**Status:** [x] COMPLETE
|
||||
|
||||
- [x] Run full test suite: `uv run pytest` - 765 tests pass (including 28 new Phase 12 tests)
|
||||
- [x] Run linter: `uv run ruff check .` - Clean (only pre-existing issues in references/)
|
||||
- [ ] Run type checker: `uv run mypy app` - Not run (pre-existing type issues exist)
|
||||
- [x] Review all changes
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `app/core/models/card.py` | Model fields + methods |
|
||||
| `app/core/engine.py` | Attach/evolve/retreat execution + status clear fix |
|
||||
| `app/core/models/game_state.py` | find_card_instance search |
|
||||
| `app/core/turn_manager.py` | Knockout processing |
|
||||
| `app/core/effects/handlers.py` | discard_energy update + new devolve + EffectType fix |
|
||||
| `app/core/rules_validator.py` | Energy type checking |
|
||||
| `tests/core/test_models/test_card.py` | Test updates |
|
||||
| `tests/core/test_engine.py` | Test updates |
|
||||
| `tests/core/test_effects/test_handlers.py` | Test updates + new tests |
|
||||
| `tests/core/test_rules_validator.py` | Test updates |
|
||||
| `tests/core/conftest.py` | Fixture updates (energy attachment) |
|
||||
| `tests/core/test_evolution_stack.py` | NEW: 28 comprehensive tests for Phase 12 |
|
||||
|
||||
---
|
||||
|
||||
## Related Issues
|
||||
|
||||
- SYSTEM_REVIEW.md Issue #5: Energy Attachment Bug - Energy Card Disappears
|
||||
- SYSTEM_REVIEW.md Issue #8: Energy Discard Handler Doesn't Move Cards
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Future hook point needed for effects that redirect energy/tools on knockout
|
||||
- Event system design should be planned holistically for the entire engine
|
||||
- Devolve is effect-only; no DevolveAction needed
|
||||
@ -31,6 +31,9 @@ Available Effects:
|
||||
- discard_energy: Discard energy from a Pokemon
|
||||
- modify_hp: Change a Pokemon's HP modifier
|
||||
- modify_retreat_cost: Change a Pokemon's retreat cost modifier
|
||||
|
||||
Evolution:
|
||||
- devolve: Remove evolution stages from a Pokemon
|
||||
"""
|
||||
|
||||
from app.core.effects.base import EffectContext, EffectResult, EffectType
|
||||
@ -465,7 +468,7 @@ def handle_coin_flip_damage(ctx: EffectContext) -> EffectResult:
|
||||
|
||||
@effect_handler("discard_energy")
|
||||
def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
||||
"""Discard energy from a Pokemon.
|
||||
"""Discard energy from a Pokemon to its owner's discard pile.
|
||||
|
||||
Params:
|
||||
count (int): Number of energy cards to discard. Default 1.
|
||||
@ -476,8 +479,8 @@ def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
||||
If target_card_id is set, discards from that Pokemon.
|
||||
Otherwise, discards from source player's active Pokemon.
|
||||
|
||||
Note: This only removes the energy from the Pokemon's attached_energy list.
|
||||
The actual energy CardInstance should be moved to discard by the game engine.
|
||||
Note: Energy CardInstances are stored directly on the Pokemon. When discarded,
|
||||
they are moved to the Pokemon owner's discard pile.
|
||||
|
||||
Returns:
|
||||
Success with energy discarded.
|
||||
@ -488,22 +491,35 @@ def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
||||
if target is None:
|
||||
return EffectResult.failure("No valid target for energy discard")
|
||||
|
||||
# Find the owner of the target Pokemon to access their discard pile
|
||||
owner = None
|
||||
for player in ctx.game.players.values():
|
||||
if target.instance_id in player.active or target.instance_id in player.bench:
|
||||
owner = player
|
||||
break
|
||||
|
||||
if owner is None:
|
||||
return EffectResult.failure("Could not find owner of target Pokemon")
|
||||
|
||||
energy_ids = ctx.get_param("energy_ids")
|
||||
count = ctx.get_int_param("count", 1)
|
||||
|
||||
discarded = []
|
||||
discarded: list[str] = []
|
||||
|
||||
if energy_ids:
|
||||
# Discard specific energy
|
||||
# Discard specific energy by ID
|
||||
for energy_id in energy_ids:
|
||||
if target.detach_energy(energy_id):
|
||||
energy = target.detach_energy(energy_id)
|
||||
if energy:
|
||||
owner.discard.add(energy)
|
||||
discarded.append(energy_id)
|
||||
else:
|
||||
# Discard from end of list
|
||||
for _ in range(count):
|
||||
if target.attached_energy:
|
||||
energy_id = target.attached_energy.pop()
|
||||
discarded.append(energy_id)
|
||||
energy = target.attached_energy.pop()
|
||||
owner.discard.add(energy)
|
||||
discarded.append(energy.instance_id)
|
||||
else:
|
||||
break
|
||||
|
||||
@ -518,6 +534,125 @@ def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
||||
)
|
||||
|
||||
|
||||
@effect_handler("devolve")
|
||||
def handle_devolve(ctx: EffectContext) -> EffectResult:
|
||||
"""Devolve a Pokemon by removing evolution stages.
|
||||
|
||||
Removes evolution cards from a Pokemon, reverting it to a previous evolution
|
||||
stage. Energy, tools, damage, and status conditions remain on the devolved
|
||||
Pokemon. If damage exceeds the devolved Pokemon's HP, it is knocked out.
|
||||
|
||||
Params:
|
||||
stages (int): Number of evolution stages to remove. Default 1.
|
||||
- 1: Remove most recent evolution (Stage 2 -> Stage 1, or Stage 1 -> Basic)
|
||||
- 2+: Remove multiple stages (Stage 2 -> Basic with stages=2)
|
||||
destination (str): Where removed evolution cards go. Default "hand".
|
||||
- "hand": Return removed evolutions to owner's hand
|
||||
- "discard": Send removed evolutions to owner's discard pile
|
||||
|
||||
Target:
|
||||
The evolved Pokemon to devolve (via target_card_id). Must be an evolved
|
||||
Pokemon (has cards in cards_underneath).
|
||||
|
||||
Returns:
|
||||
Success with details of removed cards, or failure if target cannot devolve.
|
||||
Includes knockout=True in details if devolve caused a knockout.
|
||||
"""
|
||||
target = ctx.get_target_card()
|
||||
if target is None:
|
||||
return EffectResult.failure("No target specified for devolve")
|
||||
|
||||
if not target.cards_underneath:
|
||||
return EffectResult.failure("Target is not an evolved Pokemon (no cards underneath)")
|
||||
|
||||
# Find target's owner and zone
|
||||
owner = None
|
||||
zone = None
|
||||
for player in ctx.game.players.values():
|
||||
if target.instance_id in player.active:
|
||||
owner = player
|
||||
zone = player.active
|
||||
break
|
||||
elif target.instance_id in player.bench:
|
||||
owner = player
|
||||
zone = player.bench
|
||||
break
|
||||
|
||||
if owner is None or zone is None:
|
||||
return EffectResult.failure("Could not find target Pokemon in play")
|
||||
|
||||
stages = ctx.get_int_param("stages", 1)
|
||||
destination = ctx.get_str_param("destination", "hand")
|
||||
|
||||
removed_cards: list[str] = []
|
||||
current = target
|
||||
|
||||
for _ in range(stages):
|
||||
if not current.cards_underneath:
|
||||
break # Can't devolve further
|
||||
|
||||
# Get the previous evolution from the stack
|
||||
previous = current.cards_underneath.pop()
|
||||
|
||||
# Transfer all state from current to previous
|
||||
# Energy, tools, and remaining stack stay with the Pokemon
|
||||
previous.attached_energy = current.attached_energy
|
||||
previous.attached_tools = current.attached_tools
|
||||
previous.cards_underneath = current.cards_underneath
|
||||
previous.damage = current.damage
|
||||
previous.status_conditions = current.status_conditions.copy()
|
||||
previous.hp_modifier = current.hp_modifier
|
||||
previous.damage_modifier = current.damage_modifier
|
||||
previous.retreat_cost_modifier = current.retreat_cost_modifier
|
||||
|
||||
# Clear current's lists (they're now on previous)
|
||||
current.attached_energy = []
|
||||
current.attached_tools = []
|
||||
current.cards_underneath = []
|
||||
|
||||
# Record the removed card
|
||||
removed_cards.append(current.instance_id)
|
||||
|
||||
# Send removed evolution to destination
|
||||
if destination == "hand":
|
||||
owner.hand.add(current)
|
||||
else:
|
||||
owner.discard.add(current)
|
||||
|
||||
# Swap in zone: remove current, add previous
|
||||
zone.remove(current.instance_id)
|
||||
zone.add(previous)
|
||||
|
||||
# Previous becomes the new current for next iteration
|
||||
current = previous
|
||||
|
||||
result_details: dict = {
|
||||
"removed_count": len(removed_cards),
|
||||
"removed_ids": removed_cards,
|
||||
"destination": destination,
|
||||
"devolved_pokemon_id": current.instance_id,
|
||||
}
|
||||
|
||||
# Check for knockout after devolve (damage may exceed new lower HP)
|
||||
knockout_occurred = False
|
||||
if removed_cards:
|
||||
card_def = ctx.game.get_card_definition(current.definition_id)
|
||||
if card_def and card_def.hp and current.is_knocked_out(card_def.hp):
|
||||
knockout_occurred = True
|
||||
result_details["knockout"] = True
|
||||
result_details["knockout_pokemon_id"] = current.instance_id
|
||||
|
||||
message = f"Devolved {len(removed_cards)} stage(s)"
|
||||
if knockout_occurred:
|
||||
message += f" - {current.definition_id} knocked out!"
|
||||
|
||||
return EffectResult.success_result(
|
||||
message,
|
||||
effect_type=EffectType.SPECIAL, # Devolve is a unique effect
|
||||
details=result_details,
|
||||
)
|
||||
|
||||
|
||||
@effect_handler("modify_hp")
|
||||
def handle_modify_hp(ctx: EffectContext) -> EffectResult:
|
||||
"""Modify a Pokemon's HP modifier.
|
||||
|
||||
@ -55,7 +55,7 @@ from app.core.models.actions import (
|
||||
UseAbilityAction,
|
||||
)
|
||||
from app.core.models.card import CardDefinition, CardInstance
|
||||
from app.core.models.enums import TurnPhase
|
||||
from app.core.models.enums import StatusCondition, TurnPhase
|
||||
from app.core.models.game_state import GameState, PlayerState
|
||||
from app.core.rng import RandomProvider, create_rng
|
||||
from app.core.rules_validator import ValidationResult, validate_action
|
||||
@ -504,17 +504,37 @@ class GameEngine:
|
||||
player.hand.add(evo_card)
|
||||
return ActionResult(success=False, message="Target Pokemon not found")
|
||||
|
||||
# Transfer energy and damage to evolution
|
||||
evo_card.attached_energy = target.attached_energy.copy()
|
||||
# Transfer all attached cards to the evolution (energy, tools stay attached)
|
||||
evo_card.attached_energy = target.attached_energy
|
||||
evo_card.attached_tools = target.attached_tools
|
||||
evo_card.damage = target.damage
|
||||
evo_card.turn_played = game.turn_number
|
||||
# Note: Status conditions are NOT transferred - evolution removes status in Pokemon TCG
|
||||
evo_card.status_conditions = []
|
||||
evo_card.hp_modifier = target.hp_modifier
|
||||
evo_card.damage_modifier = target.damage_modifier
|
||||
evo_card.retreat_cost_modifier = target.retreat_cost_modifier
|
||||
|
||||
# Remove old Pokemon and add evolution
|
||||
# Build evolution stack - previous stages go underneath
|
||||
# Copy existing stack and add the target (previous evolution) to it
|
||||
evo_card.cards_underneath = target.cards_underneath.copy()
|
||||
|
||||
# Clear target's attached lists since they're now on evo_card
|
||||
target.attached_energy = []
|
||||
target.attached_tools = []
|
||||
target.cards_underneath = []
|
||||
|
||||
# Add target to the evolution stack (it goes underneath the new evolution)
|
||||
evo_card.cards_underneath.append(target)
|
||||
|
||||
# Track evolution timing
|
||||
evo_card.turn_played = game.turn_number
|
||||
evo_card.turn_evolved = game.turn_number
|
||||
|
||||
# Remove old Pokemon from zone and add evolution
|
||||
zone.remove(action.target_pokemon_id)
|
||||
zone.add(evo_card)
|
||||
|
||||
# Discard old Pokemon
|
||||
player.discard.add(target)
|
||||
# Note: Target is NOT discarded - it's now in cards_underneath
|
||||
|
||||
return ActionResult(
|
||||
success=True,
|
||||
@ -564,8 +584,9 @@ class GameEngine:
|
||||
player.hand.add(energy_card)
|
||||
return ActionResult(success=False, message="Target Pokemon not found")
|
||||
|
||||
# Attach energy
|
||||
target.attach_energy(energy_card.instance_id)
|
||||
# Attach energy - the CardInstance is stored directly on the Pokemon
|
||||
# Energy stays attached until the Pokemon is knocked out or an effect removes it
|
||||
target.attach_energy(energy_card)
|
||||
player.energy_attachments_this_turn += 1
|
||||
|
||||
return ActionResult(
|
||||
@ -679,7 +700,11 @@ class GameEngine:
|
||||
player: PlayerState,
|
||||
action: AttackAction,
|
||||
) -> ActionResult:
|
||||
"""Execute an attack."""
|
||||
"""Execute an attack.
|
||||
|
||||
Handles confusion status: confused Pokemon must flip a coin before attacking.
|
||||
On tails, the attack fails and the Pokemon damages itself.
|
||||
"""
|
||||
active = player.get_active_pokemon()
|
||||
if not active:
|
||||
return ActionResult(success=False, message="No active Pokemon")
|
||||
@ -693,6 +718,41 @@ class GameEngine:
|
||||
|
||||
attack = card_def.attacks[action.attack_index]
|
||||
|
||||
# Handle confusion: flip coin, tails = attack fails + self-damage
|
||||
if StatusCondition.CONFUSED in active.status_conditions:
|
||||
confusion_flip = self.rng.coin_flip()
|
||||
if not confusion_flip:
|
||||
# Tails - attack fails, Pokemon damages itself
|
||||
self_damage = game.rules.status.confusion_self_damage
|
||||
active.damage += self_damage
|
||||
|
||||
# Check if attacker knocked itself out from confusion
|
||||
win_result = None
|
||||
if card_def.hp and active.is_knocked_out(card_def.hp):
|
||||
opponent_id = game.get_opponent_id(player.player_id)
|
||||
ko_result = self.turn_manager.process_knockout(
|
||||
game, active.instance_id, opponent_id
|
||||
)
|
||||
if ko_result:
|
||||
win_result = ko_result
|
||||
|
||||
# Advance to END phase (attack was attempted even though it failed)
|
||||
self.turn_manager.advance_to_end(game)
|
||||
|
||||
return ActionResult(
|
||||
success=True, # Action succeeded (coin was flipped), but attack failed
|
||||
message=f"Confused! Flipped tails - attack failed, {self_damage} self-damage",
|
||||
win_result=win_result,
|
||||
state_changes=[
|
||||
{
|
||||
"type": "confusion_flip",
|
||||
"result": "tails",
|
||||
"self_damage": self_damage,
|
||||
}
|
||||
],
|
||||
)
|
||||
# Heads - attack proceeds normally (fall through)
|
||||
|
||||
# Get opponent's active Pokemon
|
||||
opponent_id = game.get_opponent_id(player.player_id)
|
||||
opponent = game.players[opponent_id]
|
||||
@ -718,11 +778,20 @@ class GameEngine:
|
||||
# Advance to END phase after attack
|
||||
self.turn_manager.advance_to_end(game)
|
||||
|
||||
# Build message - include confusion heads if applicable
|
||||
message = f"Attack: {attack.name} dealt {base_damage} damage"
|
||||
state_changes: list[dict[str, Any]] = [
|
||||
{"type": "attack", "name": attack.name, "damage": base_damage}
|
||||
]
|
||||
if StatusCondition.CONFUSED in active.status_conditions:
|
||||
message = f"Confused - flipped heads! {message}"
|
||||
state_changes.insert(0, {"type": "confusion_flip", "result": "heads"})
|
||||
|
||||
return ActionResult(
|
||||
success=True,
|
||||
message=f"Attack: {attack.name} dealt {base_damage} damage",
|
||||
message=message,
|
||||
win_result=win_result,
|
||||
state_changes=[{"type": "attack", "name": attack.name, "damage": base_damage}],
|
||||
state_changes=state_changes,
|
||||
)
|
||||
|
||||
def _execute_retreat(
|
||||
@ -743,7 +812,9 @@ class GameEngine:
|
||||
|
||||
# Discard energy for retreat cost (simplified - assume cost already validated)
|
||||
for energy_id in action.energy_to_discard:
|
||||
active.detach_energy(energy_id)
|
||||
energy = active.detach_energy(energy_id)
|
||||
if energy:
|
||||
player.discard.add(energy)
|
||||
|
||||
# Swap positions
|
||||
player.active.remove(active.instance_id)
|
||||
|
||||
@ -299,8 +299,14 @@ class CardInstance(BaseModel):
|
||||
instance_id: Unique identifier for this specific instance (UUID).
|
||||
definition_id: Reference to the CardDefinition.id.
|
||||
damage: Current damage on this card (Pokemon only).
|
||||
attached_energy: List of CardInstance IDs for attached energy cards.
|
||||
attached_tools: List of CardInstance IDs for attached tool cards.
|
||||
attached_energy: Energy cards attached to this Pokemon. These CardInstance
|
||||
objects are stored directly on the Pokemon, not in any zone.
|
||||
attached_tools: Tool cards attached to this Pokemon. These CardInstance
|
||||
objects are stored directly on the Pokemon, not in any zone.
|
||||
cards_underneath: Evolution stack - previous evolution stages are stored
|
||||
here when a Pokemon evolves. Index 0 is the oldest (Basic), and
|
||||
later indices are more recent evolutions. When the Pokemon is knocked
|
||||
out, all cards in this stack go to the discard pile.
|
||||
status_conditions: Active status conditions on this Pokemon.
|
||||
ability_uses_this_turn: Number of times abilities have been used this turn.
|
||||
Compared against Ability.uses_per_turn to determine if more uses allowed.
|
||||
@ -316,7 +322,8 @@ class CardInstance(BaseModel):
|
||||
attack index is not in this dict, the base cost from CardDefinition is used.
|
||||
Example: {0: [EnergyType.COLORLESS, EnergyType.COLORLESS]} makes the first
|
||||
attack cost 2 colorless instead of its normal cost.
|
||||
evolved_from_instance_id: The CardInstance this evolved from.
|
||||
evolved_from_instance_id: The CardInstance this evolved from (deprecated,
|
||||
use cards_underneath instead for the full evolution stack).
|
||||
turn_played: The turn number when this card was played/evolved.
|
||||
Used for evolution timing rules.
|
||||
turn_evolved: The turn number when this card last evolved.
|
||||
@ -328,8 +335,9 @@ class CardInstance(BaseModel):
|
||||
|
||||
# Battle state (Pokemon only)
|
||||
damage: int = 0
|
||||
attached_energy: list[str] = Field(default_factory=list)
|
||||
attached_tools: list[str] = Field(default_factory=list)
|
||||
attached_energy: list["CardInstance"] = Field(default_factory=list)
|
||||
attached_tools: list["CardInstance"] = Field(default_factory=list)
|
||||
cards_underneath: list["CardInstance"] = Field(default_factory=list)
|
||||
status_conditions: list[StatusCondition] = Field(default_factory=list)
|
||||
ability_uses_this_turn: int = 0
|
||||
|
||||
@ -483,24 +491,65 @@ class CardInstance(BaseModel):
|
||||
"""Get the number of attached energy cards."""
|
||||
return len(self.attached_energy)
|
||||
|
||||
def attach_energy(self, energy_instance_id: str) -> None:
|
||||
def attach_energy(self, energy_card: "CardInstance") -> None:
|
||||
"""Attach an energy card to this Pokemon.
|
||||
|
||||
Args:
|
||||
energy_instance_id: The CardInstance.instance_id of the energy card.
|
||||
"""
|
||||
self.attached_energy.append(energy_instance_id)
|
||||
The energy CardInstance is stored directly on this Pokemon, not in any zone.
|
||||
When the Pokemon is knocked out, attached energy goes to the owner's discard.
|
||||
|
||||
def detach_energy(self, energy_instance_id: str) -> bool:
|
||||
Args:
|
||||
energy_card: The CardInstance of the energy card to attach.
|
||||
"""
|
||||
self.attached_energy.append(energy_card)
|
||||
|
||||
def detach_energy(self, energy_instance_id: str) -> "CardInstance | None":
|
||||
"""Detach an energy card from this Pokemon.
|
||||
|
||||
Args:
|
||||
energy_instance_id: The CardInstance.instance_id of the energy card.
|
||||
energy_instance_id: The instance_id of the energy card to detach.
|
||||
|
||||
Returns:
|
||||
True if the energy was found and removed, False otherwise.
|
||||
The detached CardInstance, or None if not found.
|
||||
"""
|
||||
if energy_instance_id in self.attached_energy:
|
||||
self.attached_energy.remove(energy_instance_id)
|
||||
return True
|
||||
return False
|
||||
for i, energy in enumerate(self.attached_energy):
|
||||
if energy.instance_id == energy_instance_id:
|
||||
return self.attached_energy.pop(i)
|
||||
return None
|
||||
|
||||
def attach_tool(self, tool_card: "CardInstance") -> None:
|
||||
"""Attach a tool card to this Pokemon.
|
||||
|
||||
The tool CardInstance is stored directly on this Pokemon, not in any zone.
|
||||
When the Pokemon is knocked out, attached tools go to the owner's discard.
|
||||
|
||||
Args:
|
||||
tool_card: The CardInstance of the tool card to attach.
|
||||
"""
|
||||
self.attached_tools.append(tool_card)
|
||||
|
||||
def detach_tool(self, tool_instance_id: str) -> "CardInstance | None":
|
||||
"""Detach a tool card from this Pokemon.
|
||||
|
||||
Args:
|
||||
tool_instance_id: The instance_id of the tool card to detach.
|
||||
|
||||
Returns:
|
||||
The detached CardInstance, or None if not found.
|
||||
"""
|
||||
for i, tool in enumerate(self.attached_tools):
|
||||
if tool.instance_id == tool_instance_id:
|
||||
return self.attached_tools.pop(i)
|
||||
return None
|
||||
|
||||
def get_all_attached_cards(self) -> list["CardInstance"]:
|
||||
"""Get all cards attached to this Pokemon.
|
||||
|
||||
Returns:
|
||||
List of all attached energy and tool CardInstances.
|
||||
"""
|
||||
return self.attached_energy + self.attached_tools
|
||||
|
||||
|
||||
# Rebuild model to resolve forward references for self-referential types
|
||||
# (CardInstance contains list[CardInstance] for attached_energy, attached_tools, cards_underneath)
|
||||
CardInstance.model_rebuild()
|
||||
|
||||
@ -521,15 +521,21 @@ class GameState(BaseModel):
|
||||
def find_card_instance(self, instance_id: str) -> tuple[CardInstance | None, str | None]:
|
||||
"""Find a CardInstance anywhere in the game.
|
||||
|
||||
Searches all zones of all players for the card.
|
||||
Searches all zones of all players for the card, including cards attached
|
||||
to Pokemon (energy, tools) and cards underneath evolved Pokemon.
|
||||
|
||||
Args:
|
||||
instance_id: The instance_id to search for.
|
||||
|
||||
Returns:
|
||||
Tuple of (CardInstance, zone_type) if found, (None, None) if not.
|
||||
Zone types include standard zones plus:
|
||||
- "attached_energy": Energy attached to a Pokemon in play
|
||||
- "attached_tools": Tools attached to a Pokemon in play
|
||||
- "cards_underneath": Cards in an evolution stack
|
||||
"""
|
||||
for player in self.players.values():
|
||||
# Search standard zones
|
||||
for zone_name in [
|
||||
"deck",
|
||||
"hand",
|
||||
@ -538,9 +544,26 @@ class GameState(BaseModel):
|
||||
"discard",
|
||||
"prizes",
|
||||
"energy_deck",
|
||||
"energy_zone",
|
||||
]:
|
||||
zone: Zone = getattr(player, zone_name)
|
||||
card = zone.get(instance_id)
|
||||
if card:
|
||||
return card, zone_name
|
||||
|
||||
# Search cards attached to Pokemon in play (active and bench)
|
||||
for pokemon in player.get_all_pokemon_in_play():
|
||||
# Check attached energy
|
||||
for energy in pokemon.attached_energy:
|
||||
if energy.instance_id == instance_id:
|
||||
return energy, "attached_energy"
|
||||
# Check attached tools
|
||||
for tool in pokemon.attached_tools:
|
||||
if tool.instance_id == instance_id:
|
||||
return tool, "attached_tools"
|
||||
# Check evolution stack (cards underneath)
|
||||
for card in pokemon.cards_underneath:
|
||||
if card.instance_id == instance_id:
|
||||
return card, "cards_underneath"
|
||||
|
||||
return None, None
|
||||
|
||||
@ -251,14 +251,10 @@ def _get_attached_energy_types(game: GameState, pokemon: CardInstance) -> list[E
|
||||
"""
|
||||
energy_types: list[EnergyType] = []
|
||||
|
||||
for energy_id in pokemon.attached_energy:
|
||||
# Find the energy card instance
|
||||
card_instance, _ = game.find_card_instance(energy_id)
|
||||
if card_instance is None:
|
||||
continue
|
||||
|
||||
# Energy cards are now CardInstance objects stored directly on the Pokemon
|
||||
for energy_card in pokemon.attached_energy:
|
||||
# Get the definition
|
||||
definition = game.card_registry.get(card_instance.definition_id)
|
||||
definition = game.card_registry.get(energy_card.definition_id)
|
||||
if definition is None:
|
||||
continue
|
||||
|
||||
@ -851,8 +847,10 @@ def _validate_retreat(
|
||||
)
|
||||
|
||||
# Check that all energy to discard is actually attached
|
||||
# attached_energy is now a list of CardInstance objects
|
||||
attached_energy_ids = [e.instance_id for e in active.attached_energy]
|
||||
for energy_id in action.energy_to_discard:
|
||||
if energy_id not in active.attached_energy:
|
||||
if energy_id not in attached_energy_ids:
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
reason=f"Energy {energy_id} is not attached to active Pokemon",
|
||||
|
||||
@ -50,7 +50,7 @@ from typing import TYPE_CHECKING
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.models.enums import GameEndReason, StatusCondition, TurnPhase
|
||||
from app.core.win_conditions import WinResult, check_cannot_draw, check_no_pokemon_in_play
|
||||
from app.core.win_conditions import WinResult, check_cannot_draw
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.core.models.game_state import GameState
|
||||
@ -316,8 +316,9 @@ class TurnManager:
|
||||
1. Applies poison/burn damage to the current player's active Pokemon
|
||||
2. Checks for status recovery (burn flip, sleep flip)
|
||||
3. Removes paralysis (wears off after one turn)
|
||||
4. Checks for knockouts from status damage
|
||||
5. Advances to the next player's turn
|
||||
4. Processes knockouts from status damage (moves Pokemon to discard, awards points)
|
||||
5. Checks win conditions (after knockout processing)
|
||||
6. Advances to the next player's turn
|
||||
|
||||
Call this when the current player's turn should end.
|
||||
|
||||
@ -400,13 +401,16 @@ class TurnManager:
|
||||
knockouts.append(active.instance_id)
|
||||
messages.append(f"{card_def.name} knocked out by status damage!")
|
||||
|
||||
# Check for win conditions after status knockouts
|
||||
# Handle knockout - move Pokemon to discard, check for game end
|
||||
# Note: The actual knockout handling (scoring, forced active selection)
|
||||
# should be handled by the game engine. We just report the knockout here.
|
||||
# Process knockouts BEFORE checking win conditions
|
||||
# The opponent (who will be the next player) scores points for status KOs
|
||||
win_result = None
|
||||
if knockouts and rules.win_conditions.no_pokemon_in_play:
|
||||
win_result = check_no_pokemon_in_play(game)
|
||||
opponent_id = game.get_opponent_id(player.player_id)
|
||||
|
||||
for knocked_out_id in knockouts:
|
||||
ko_result = self.process_knockout(game, knocked_out_id, opponent_id)
|
||||
if ko_result:
|
||||
win_result = ko_result
|
||||
break # Game ended
|
||||
|
||||
# Advance to next player's turn
|
||||
game.advance_turn()
|
||||
@ -445,8 +449,28 @@ class TurnManager:
|
||||
for player_id, player in game.players.items():
|
||||
card = player.active.get(knocked_out_id)
|
||||
if card:
|
||||
# Found in active - remove and discard
|
||||
# Found in active - remove from zone
|
||||
player.active.remove(knocked_out_id)
|
||||
|
||||
# TODO: Future hook point - pre_knockout_discard event
|
||||
# This would allow effects to redirect energy/tools elsewhere
|
||||
|
||||
# Discard all attached energy
|
||||
for energy in card.attached_energy:
|
||||
player.discard.add(energy)
|
||||
card.attached_energy = []
|
||||
|
||||
# Discard all attached tools
|
||||
for tool in card.attached_tools:
|
||||
player.discard.add(tool)
|
||||
card.attached_tools = []
|
||||
|
||||
# Discard entire evolution stack (cards underneath)
|
||||
for underneath in card.cards_underneath:
|
||||
player.discard.add(underneath)
|
||||
card.cards_underneath = []
|
||||
|
||||
# Finally discard the Pokemon itself
|
||||
player.discard.add(card)
|
||||
|
||||
# Award points to opponent
|
||||
@ -490,7 +514,27 @@ class TurnManager:
|
||||
# Check bench too (for bench damage knockouts)
|
||||
card = player.bench.get(knocked_out_id)
|
||||
if card:
|
||||
# Remove from bench
|
||||
player.bench.remove(knocked_out_id)
|
||||
|
||||
# TODO: Future hook point - pre_knockout_discard event
|
||||
|
||||
# Discard all attached energy
|
||||
for energy in card.attached_energy:
|
||||
player.discard.add(energy)
|
||||
card.attached_energy = []
|
||||
|
||||
# Discard all attached tools
|
||||
for tool in card.attached_tools:
|
||||
player.discard.add(tool)
|
||||
card.attached_tools = []
|
||||
|
||||
# Discard entire evolution stack
|
||||
for underneath in card.cards_underneath:
|
||||
player.discard.add(underneath)
|
||||
card.cards_underneath = []
|
||||
|
||||
# Discard the Pokemon
|
||||
player.discard.add(card)
|
||||
|
||||
# Award points
|
||||
|
||||
@ -744,17 +744,12 @@ def game_in_main_phase(extended_card_registry, card_instance_factory) -> GameSta
|
||||
|
||||
# Player 1 setup - active with energy, bench pokemon, cards in hand
|
||||
pikachu = card_instance_factory("pikachu_base_001", turn_played=1)
|
||||
pikachu.attach_energy("energy_lightning_1")
|
||||
# Attach energy as a CardInstance - energy is stored directly on the Pokemon
|
||||
energy = card_instance_factory("lightning_energy_001", instance_id="energy_lightning_1")
|
||||
pikachu.attach_energy(energy)
|
||||
player1.active.add(pikachu)
|
||||
player1.bench.add(card_instance_factory("charmander_base_001", turn_played=1))
|
||||
|
||||
# The attached energy card needs to exist somewhere so find_card_instance can find it
|
||||
# In real gameplay, attached energy stays "on" the Pokemon but is tracked by ID
|
||||
# For testing, we put it in discard (where it can be found but isn't "in hand")
|
||||
player1.discard.add(
|
||||
card_instance_factory("lightning_energy_001", instance_id="energy_lightning_1")
|
||||
)
|
||||
|
||||
# Cards in hand: evolution card, energy, trainer
|
||||
player1.hand.add(card_instance_factory("raichu_base_001", instance_id="hand_raichu"))
|
||||
player1.hand.add(card_instance_factory("charmeleon_base_001", instance_id="hand_charmeleon"))
|
||||
@ -804,15 +799,12 @@ def game_in_attack_phase(extended_card_registry, card_instance_factory) -> GameS
|
||||
|
||||
# Player 1 - Pikachu with 1 lightning energy (enough for Thunder Shock)
|
||||
pikachu = card_instance_factory("pikachu_base_001", turn_played=1)
|
||||
pikachu.attach_energy("energy_lightning_1")
|
||||
# Attach energy as a CardInstance - energy is stored directly on the Pokemon
|
||||
energy = card_instance_factory("lightning_energy_001", instance_id="energy_lightning_1")
|
||||
pikachu.attach_energy(energy)
|
||||
player1.active.add(pikachu)
|
||||
player1.bench.add(card_instance_factory("charmander_base_001", turn_played=1))
|
||||
|
||||
# The attached energy card needs to exist somewhere so find_card_instance can find it
|
||||
player1.discard.add(
|
||||
card_instance_factory("lightning_energy_001", instance_id="energy_lightning_1")
|
||||
)
|
||||
|
||||
for i in range(10):
|
||||
player1.deck.add(card_instance_factory("pikachu_base_001", instance_id=f"deck_{i}"))
|
||||
|
||||
|
||||
@ -1341,10 +1341,20 @@ class TestDiscardEnergy:
|
||||
def test_discards_energy_from_source(self, game_state: GameState, rng: SeededRandom) -> None:
|
||||
"""
|
||||
Verify discard_energy removes energy from source Pokemon by default.
|
||||
|
||||
Energy is now stored as CardInstance objects directly on the Pokemon.
|
||||
When discarded, energy moves to the owner's discard pile.
|
||||
"""
|
||||
from app.core.models.card import CardInstance
|
||||
|
||||
source = game_state.players["player1"].get_active_pokemon()
|
||||
assert source is not None
|
||||
source.attached_energy = ["energy-1", "energy-2", "energy-3"]
|
||||
# Attach energy as CardInstance objects
|
||||
source.attached_energy = [
|
||||
CardInstance(instance_id="energy-1", definition_id="fire_energy"),
|
||||
CardInstance(instance_id="energy-2", definition_id="fire_energy"),
|
||||
CardInstance(instance_id="energy-3", definition_id="fire_energy"),
|
||||
]
|
||||
|
||||
ctx = make_context(game_state, rng, params={"count": 2})
|
||||
|
||||
@ -1354,14 +1364,24 @@ class TestDiscardEnergy:
|
||||
assert result.effect_type == EffectType.ENERGY
|
||||
assert len(source.attached_energy) == 1
|
||||
assert result.details["count"] == 2
|
||||
# Check that discarded energy went to discard pile
|
||||
assert len(game_state.players["player1"].discard) >= 2
|
||||
|
||||
def test_discards_specific_energy(self, game_state: GameState, rng: SeededRandom) -> None:
|
||||
"""
|
||||
Verify discard_energy can discard specific energy cards.
|
||||
|
||||
Energy is now stored as CardInstance objects.
|
||||
"""
|
||||
from app.core.models.card import CardInstance
|
||||
|
||||
source = game_state.players["player1"].get_active_pokemon()
|
||||
assert source is not None
|
||||
source.attached_energy = ["energy-1", "energy-2", "energy-3"]
|
||||
source.attached_energy = [
|
||||
CardInstance(instance_id="energy-1", definition_id="fire_energy"),
|
||||
CardInstance(instance_id="energy-2", definition_id="fire_energy"),
|
||||
CardInstance(instance_id="energy-3", definition_id="fire_energy"),
|
||||
]
|
||||
|
||||
ctx = make_context(
|
||||
game_state,
|
||||
@ -1372,7 +1392,9 @@ class TestDiscardEnergy:
|
||||
result = resolve_effect("discard_energy", ctx)
|
||||
|
||||
assert result.success
|
||||
assert source.attached_energy == ["energy-2"]
|
||||
# Only energy-2 should remain
|
||||
assert len(source.attached_energy) == 1
|
||||
assert source.attached_energy[0].instance_id == "energy-2"
|
||||
|
||||
def test_fails_with_no_energy(self, game_state: GameState, rng: SeededRandom) -> None:
|
||||
"""
|
||||
|
||||
@ -471,9 +471,9 @@ class TestActionExecution:
|
||||
assert result.success
|
||||
assert "Energy attached" in result.message
|
||||
|
||||
# Verify energy is attached
|
||||
# Verify energy is attached (now stored as CardInstance objects)
|
||||
active = ready_game.players["player1"].get_active_pokemon()
|
||||
assert "p1-energy-hand" in active.attached_energy
|
||||
assert any(e.instance_id == "p1-energy-hand" for e in active.attached_energy)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_attack(
|
||||
@ -485,12 +485,10 @@ class TestActionExecution:
|
||||
"""
|
||||
Test executing an attack action.
|
||||
"""
|
||||
# Attach energy - the energy must be in a zone so find_card_instance works
|
||||
# Put it in discard pile (energy stays there after being attached for tracking)
|
||||
# Attach energy - energy CardInstance is stored directly on the Pokemon
|
||||
p1 = ready_game.players["player1"]
|
||||
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
||||
p1.discard.add(energy) # Must be findable by find_card_instance
|
||||
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
||||
p1.get_active_pokemon().attach_energy(energy)
|
||||
|
||||
# Need to be in ATTACK phase for attack action
|
||||
ready_game.phase = TurnPhase.ATTACK
|
||||
@ -553,9 +551,10 @@ class TestActionExecution:
|
||||
"""
|
||||
Test executing a retreat action.
|
||||
"""
|
||||
# Attach energy for retreat cost
|
||||
# Attach energy for retreat cost (now a CardInstance)
|
||||
active = ready_game.players["player1"].get_active_pokemon()
|
||||
active.attach_energy("retreat-energy")
|
||||
retreat_energy = CardInstance(instance_id="retreat-energy", definition_id="fire_energy")
|
||||
active.attach_energy(retreat_energy)
|
||||
|
||||
action = RetreatAction(
|
||||
new_active_id="p1-bench-1",
|
||||
@ -713,8 +712,8 @@ class TestWinConditions:
|
||||
# This gives player1 their 4th point, winning the game
|
||||
p1 = near_win_game.players["player1"]
|
||||
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
||||
p1.discard.add(energy) # Must be findable
|
||||
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
||||
# Energy CardInstance is now stored directly on the Pokemon
|
||||
p1.get_active_pokemon().attach_energy(energy)
|
||||
|
||||
# Need to be in ATTACK phase
|
||||
near_win_game.phase = TurnPhase.ATTACK
|
||||
@ -1124,7 +1123,9 @@ class TestEvolvePokemonAction:
|
||||
active = CardInstance(instance_id="active-pikachu", definition_id=basic_pokemon_def.id)
|
||||
active.turn_played = 1 # Played last turn, can evolve
|
||||
active.damage = 20
|
||||
active.attach_energy("attached-energy-1")
|
||||
# Attach energy as CardInstance
|
||||
energy = CardInstance(instance_id="attached-energy-1", definition_id="fire_energy")
|
||||
active.attach_energy(energy)
|
||||
p1.active.add(active)
|
||||
|
||||
# Player 1: Raichu in hand
|
||||
@ -1166,11 +1167,11 @@ class TestEvolvePokemonAction:
|
||||
assert active.definition_id == "raichu-evo"
|
||||
|
||||
# Verify energy and damage transferred
|
||||
assert "attached-energy-1" in active.attached_energy
|
||||
assert any(e.instance_id == "attached-energy-1" for e in active.attached_energy)
|
||||
assert active.damage == 20
|
||||
|
||||
# Verify old Pokemon went to discard
|
||||
assert "active-pikachu" in game.players["player1"].discard
|
||||
# Verify old Pokemon is in evolution stack (cards_underneath), not discard
|
||||
assert any(c.instance_id == "active-pikachu" for c in active.cards_underneath)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evolve_pokemon_not_in_hand(
|
||||
@ -1856,10 +1857,10 @@ class TestAttachEnergyFromEnergyZone:
|
||||
|
||||
assert result.success
|
||||
|
||||
# Energy should be attached to active
|
||||
# Energy should be attached to active (now stored as CardInstance)
|
||||
p1 = game_with_energy_zone.players["player1"]
|
||||
active = p1.get_active_pokemon()
|
||||
assert "zone-energy" in active.attached_energy
|
||||
assert any(e.instance_id == "zone-energy" for e in active.attached_energy)
|
||||
|
||||
# Energy zone should be empty
|
||||
assert "zone-energy" not in p1.energy_zone
|
||||
@ -1887,9 +1888,9 @@ class TestAttachEnergyFromEnergyZone:
|
||||
|
||||
assert result.success
|
||||
|
||||
# Energy should be attached to bench Pokemon
|
||||
# Energy should be attached to bench Pokemon (now stored as CardInstance)
|
||||
bench_pokemon = p1.bench.get("p1-bench")
|
||||
assert "zone-energy" in bench_pokemon.attached_energy
|
||||
assert any(e.instance_id == "zone-energy" for e in bench_pokemon.attached_energy)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_energy_target_not_found(
|
||||
|
||||
1169
backend/tests/core/test_evolution_stack.py
Normal file
1169
backend/tests/core/test_evolution_stack.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1320,36 +1320,43 @@ class TestCardInstanceEnergy:
|
||||
Verify energy can be attached to a Pokemon.
|
||||
"""
|
||||
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
|
||||
energy1 = CardInstance(instance_id="energy-001", definition_id="fire_energy")
|
||||
energy2 = CardInstance(instance_id="energy-002", definition_id="fire_energy")
|
||||
|
||||
instance.attach_energy("energy-001")
|
||||
instance.attach_energy("energy-002")
|
||||
instance.attach_energy(energy1)
|
||||
instance.attach_energy(energy2)
|
||||
|
||||
assert instance.energy_count() == 2
|
||||
assert "energy-001" in instance.attached_energy
|
||||
assert any(e.instance_id == "energy-001" for e in instance.attached_energy)
|
||||
|
||||
def test_detach_energy(self) -> None:
|
||||
"""
|
||||
Verify energy can be detached from a Pokemon.
|
||||
|
||||
detach_energy now returns the CardInstance or None.
|
||||
"""
|
||||
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
|
||||
instance.attach_energy("energy-001")
|
||||
instance.attach_energy("energy-002")
|
||||
energy1 = CardInstance(instance_id="energy-001", definition_id="fire_energy")
|
||||
energy2 = CardInstance(instance_id="energy-002", definition_id="fire_energy")
|
||||
instance.attach_energy(energy1)
|
||||
instance.attach_energy(energy2)
|
||||
|
||||
result = instance.detach_energy("energy-001")
|
||||
|
||||
assert result is True
|
||||
assert result is not None
|
||||
assert result.instance_id == "energy-001"
|
||||
assert instance.energy_count() == 1
|
||||
assert "energy-001" not in instance.attached_energy
|
||||
assert not any(e.instance_id == "energy-001" for e in instance.attached_energy)
|
||||
|
||||
def test_detach_nonexistent_energy(self) -> None:
|
||||
"""
|
||||
Verify detaching non-existent energy returns False.
|
||||
Verify detaching non-existent energy returns None.
|
||||
"""
|
||||
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
|
||||
|
||||
result = instance.detach_energy("nonexistent")
|
||||
|
||||
assert result is False
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCardInstanceTurnState:
|
||||
@ -1424,12 +1431,16 @@ class TestCardInstanceJsonRoundTrip:
|
||||
def test_round_trip(self) -> None:
|
||||
"""
|
||||
Verify CardInstance round-trips through JSON.
|
||||
|
||||
attached_energy is now a list of CardInstance objects.
|
||||
"""
|
||||
energy1 = CardInstance(instance_id="energy-001", definition_id="fire_energy")
|
||||
energy2 = CardInstance(instance_id="energy-002", definition_id="fire_energy")
|
||||
original = CardInstance(
|
||||
instance_id="uuid-12345",
|
||||
definition_id="pikachu_base_001",
|
||||
damage=30,
|
||||
attached_energy=["energy-001", "energy-002"],
|
||||
attached_energy=[energy1, energy2],
|
||||
status_conditions=[StatusCondition.POISONED],
|
||||
turn_played=2,
|
||||
)
|
||||
@ -1440,4 +1451,5 @@ class TestCardInstanceJsonRoundTrip:
|
||||
assert restored.instance_id == original.instance_id
|
||||
assert restored.damage == 30
|
||||
assert len(restored.attached_energy) == 2
|
||||
assert restored.attached_energy[0].instance_id == "energy-001"
|
||||
assert StatusCondition.POISONED in restored.status_conditions
|
||||
|
||||
@ -1243,39 +1243,23 @@ class TestAttackValidation:
|
||||
Verify that Colorless energy can be satisfied by any type.
|
||||
|
||||
Add a Pokemon with Colorless cost and attach Fire energy.
|
||||
Energy CardInstances are now stored directly on the Pokemon.
|
||||
"""
|
||||
player = game_in_attack_phase.players["player1"]
|
||||
|
||||
# Replace active with Raichu (costs: Lightning, Lightning, Colorless)
|
||||
player.active.clear()
|
||||
raichu = card_instance_factory("raichu_base_001", instance_id="test_raichu")
|
||||
# Create energy CardInstances and attach them directly
|
||||
energy1 = card_instance_factory("lightning_energy_001", instance_id="energy_1")
|
||||
energy2 = card_instance_factory("lightning_energy_001", instance_id="energy_2")
|
||||
energy3 = card_instance_factory("fire_energy_001", instance_id="energy_3")
|
||||
# Attach 2 Lightning + 1 Fire (Fire satisfies Colorless)
|
||||
raichu.attach_energy("energy_1")
|
||||
raichu.attach_energy("energy_2")
|
||||
raichu.attach_energy("energy_3")
|
||||
raichu.attach_energy(energy1)
|
||||
raichu.attach_energy(energy2)
|
||||
raichu.attach_energy(energy3)
|
||||
player.active.add(raichu)
|
||||
|
||||
# Add energy cards to registry so they can be found
|
||||
game_in_attack_phase.players["player1"].hand.add(
|
||||
card_instance_factory("lightning_energy_001", instance_id="energy_1")
|
||||
)
|
||||
game_in_attack_phase.players["player1"].hand.add(
|
||||
card_instance_factory("lightning_energy_001", instance_id="energy_2")
|
||||
)
|
||||
game_in_attack_phase.players["player1"].hand.add(
|
||||
card_instance_factory("fire_energy_001", instance_id="energy_3")
|
||||
)
|
||||
|
||||
# Move them to be "found" as attached
|
||||
# Actually, we need to make them findable. Let's put them in discard
|
||||
# where find_card_instance can find them
|
||||
e1 = player.hand.remove("energy_1")
|
||||
e2 = player.hand.remove("energy_2")
|
||||
e3 = player.hand.remove("energy_3")
|
||||
player.discard.add(e1)
|
||||
player.discard.add(e2)
|
||||
player.discard.add(e3)
|
||||
|
||||
action = AttackAction(attack_index=0)
|
||||
result = validate_action(game_in_attack_phase, "player1", action)
|
||||
|
||||
@ -1391,13 +1375,12 @@ class TestAttackValidation:
|
||||
# Add energy to active
|
||||
player = game_first_turn.players["player1"]
|
||||
pikachu = player.active.cards[0]
|
||||
pikachu.attach_energy("test_energy")
|
||||
player.discard.add(
|
||||
CardInstance(
|
||||
instance_id="test_energy",
|
||||
definition_id="lightning_energy_001",
|
||||
)
|
||||
# Attach energy as a CardInstance
|
||||
energy = CardInstance(
|
||||
instance_id="test_energy",
|
||||
definition_id="lightning_energy_001",
|
||||
)
|
||||
pikachu.attach_energy(energy)
|
||||
|
||||
action = AttackAction(attack_index=0)
|
||||
result = validate_action(game_first_turn, "player1", action)
|
||||
@ -1413,16 +1396,14 @@ class TestAttackValidation:
|
||||
"""
|
||||
game_first_turn.phase = TurnPhase.ATTACK
|
||||
|
||||
# Add energy to active
|
||||
# Add energy to active as CardInstance
|
||||
player = game_first_turn.players["player1"]
|
||||
pikachu = player.active.cards[0]
|
||||
pikachu.attach_energy("test_energy")
|
||||
player.discard.add(
|
||||
CardInstance(
|
||||
instance_id="test_energy",
|
||||
definition_id="lightning_energy_001",
|
||||
)
|
||||
energy = CardInstance(
|
||||
instance_id="test_energy",
|
||||
definition_id="lightning_energy_001",
|
||||
)
|
||||
pikachu.attach_energy(energy)
|
||||
|
||||
action = AttackAction(attack_index=0)
|
||||
result = validate_action(game_first_turn, "player1", action)
|
||||
@ -1888,11 +1869,9 @@ class TestEnergyCostCalculation:
|
||||
player = game_in_attack_phase.players["player1"]
|
||||
pikachu = player.active.cards[0]
|
||||
|
||||
# Add extra energy
|
||||
pikachu.attach_energy("extra_energy")
|
||||
player.discard.add(
|
||||
card_instance_factory("lightning_energy_001", instance_id="extra_energy")
|
||||
)
|
||||
# Add extra energy as CardInstance
|
||||
extra_energy = card_instance_factory("lightning_energy_001", instance_id="extra_energy")
|
||||
pikachu.attach_energy(extra_energy)
|
||||
|
||||
action = AttackAction(attack_index=0)
|
||||
result = validate_action(game_in_attack_phase, "player1", action)
|
||||
@ -1910,10 +1889,10 @@ class TestEnergyCostCalculation:
|
||||
player = game_in_attack_phase.players["player1"]
|
||||
pikachu = player.active.cards[0]
|
||||
|
||||
# Replace Lightning with Fire
|
||||
# Replace Lightning with Fire as CardInstance
|
||||
pikachu.attached_energy.clear()
|
||||
pikachu.attach_energy("fire_energy")
|
||||
player.discard.add(card_instance_factory("fire_energy_001", instance_id="fire_energy"))
|
||||
fire_energy = card_instance_factory("fire_energy_001", instance_id="fire_energy")
|
||||
pikachu.attach_energy(fire_energy)
|
||||
|
||||
action = AttackAction(attack_index=0)
|
||||
result = validate_action(game_in_attack_phase, "player1", action)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user