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
|
- discard_energy: Discard energy from a Pokemon
|
||||||
- modify_hp: Change a Pokemon's HP modifier
|
- modify_hp: Change a Pokemon's HP modifier
|
||||||
- modify_retreat_cost: Change a Pokemon's retreat cost 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
|
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")
|
@effect_handler("discard_energy")
|
||||||
def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
||||||
"""Discard energy from a Pokemon.
|
"""Discard energy from a Pokemon to its owner's discard pile.
|
||||||
|
|
||||||
Params:
|
Params:
|
||||||
count (int): Number of energy cards to discard. Default 1.
|
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.
|
If target_card_id is set, discards from that Pokemon.
|
||||||
Otherwise, discards from source player's active Pokemon.
|
Otherwise, discards from source player's active Pokemon.
|
||||||
|
|
||||||
Note: This only removes the energy from the Pokemon's attached_energy list.
|
Note: Energy CardInstances are stored directly on the Pokemon. When discarded,
|
||||||
The actual energy CardInstance should be moved to discard by the game engine.
|
they are moved to the Pokemon owner's discard pile.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Success with energy discarded.
|
Success with energy discarded.
|
||||||
@ -488,22 +491,35 @@ def handle_discard_energy(ctx: EffectContext) -> EffectResult:
|
|||||||
if target is None:
|
if target is None:
|
||||||
return EffectResult.failure("No valid target for energy discard")
|
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")
|
energy_ids = ctx.get_param("energy_ids")
|
||||||
count = ctx.get_int_param("count", 1)
|
count = ctx.get_int_param("count", 1)
|
||||||
|
|
||||||
discarded = []
|
discarded: list[str] = []
|
||||||
|
|
||||||
if energy_ids:
|
if energy_ids:
|
||||||
# Discard specific energy
|
# Discard specific energy by ID
|
||||||
for energy_id in energy_ids:
|
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)
|
discarded.append(energy_id)
|
||||||
else:
|
else:
|
||||||
# Discard from end of list
|
# Discard from end of list
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
if target.attached_energy:
|
if target.attached_energy:
|
||||||
energy_id = target.attached_energy.pop()
|
energy = target.attached_energy.pop()
|
||||||
discarded.append(energy_id)
|
owner.discard.add(energy)
|
||||||
|
discarded.append(energy.instance_id)
|
||||||
else:
|
else:
|
||||||
break
|
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")
|
@effect_handler("modify_hp")
|
||||||
def handle_modify_hp(ctx: EffectContext) -> EffectResult:
|
def handle_modify_hp(ctx: EffectContext) -> EffectResult:
|
||||||
"""Modify a Pokemon's HP modifier.
|
"""Modify a Pokemon's HP modifier.
|
||||||
|
|||||||
@ -55,7 +55,7 @@ from app.core.models.actions import (
|
|||||||
UseAbilityAction,
|
UseAbilityAction,
|
||||||
)
|
)
|
||||||
from app.core.models.card import CardDefinition, CardInstance
|
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.models.game_state import GameState, PlayerState
|
||||||
from app.core.rng import RandomProvider, create_rng
|
from app.core.rng import RandomProvider, create_rng
|
||||||
from app.core.rules_validator import ValidationResult, validate_action
|
from app.core.rules_validator import ValidationResult, validate_action
|
||||||
@ -504,17 +504,37 @@ class GameEngine:
|
|||||||
player.hand.add(evo_card)
|
player.hand.add(evo_card)
|
||||||
return ActionResult(success=False, message="Target Pokemon not found")
|
return ActionResult(success=False, message="Target Pokemon not found")
|
||||||
|
|
||||||
# Transfer energy and damage to evolution
|
# Transfer all attached cards to the evolution (energy, tools stay attached)
|
||||||
evo_card.attached_energy = target.attached_energy.copy()
|
evo_card.attached_energy = target.attached_energy
|
||||||
|
evo_card.attached_tools = target.attached_tools
|
||||||
evo_card.damage = target.damage
|
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.remove(action.target_pokemon_id)
|
||||||
zone.add(evo_card)
|
zone.add(evo_card)
|
||||||
|
|
||||||
# Discard old Pokemon
|
# Note: Target is NOT discarded - it's now in cards_underneath
|
||||||
player.discard.add(target)
|
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
success=True,
|
success=True,
|
||||||
@ -564,8 +584,9 @@ class GameEngine:
|
|||||||
player.hand.add(energy_card)
|
player.hand.add(energy_card)
|
||||||
return ActionResult(success=False, message="Target Pokemon not found")
|
return ActionResult(success=False, message="Target Pokemon not found")
|
||||||
|
|
||||||
# Attach energy
|
# Attach energy - the CardInstance is stored directly on the Pokemon
|
||||||
target.attach_energy(energy_card.instance_id)
|
# 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
|
player.energy_attachments_this_turn += 1
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -679,7 +700,11 @@ class GameEngine:
|
|||||||
player: PlayerState,
|
player: PlayerState,
|
||||||
action: AttackAction,
|
action: AttackAction,
|
||||||
) -> ActionResult:
|
) -> 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()
|
active = player.get_active_pokemon()
|
||||||
if not active:
|
if not active:
|
||||||
return ActionResult(success=False, message="No active Pokemon")
|
return ActionResult(success=False, message="No active Pokemon")
|
||||||
@ -693,6 +718,41 @@ class GameEngine:
|
|||||||
|
|
||||||
attack = card_def.attacks[action.attack_index]
|
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
|
# Get opponent's active Pokemon
|
||||||
opponent_id = game.get_opponent_id(player.player_id)
|
opponent_id = game.get_opponent_id(player.player_id)
|
||||||
opponent = game.players[opponent_id]
|
opponent = game.players[opponent_id]
|
||||||
@ -718,11 +778,20 @@ class GameEngine:
|
|||||||
# Advance to END phase after attack
|
# Advance to END phase after attack
|
||||||
self.turn_manager.advance_to_end(game)
|
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(
|
return ActionResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Attack: {attack.name} dealt {base_damage} damage",
|
message=message,
|
||||||
win_result=win_result,
|
win_result=win_result,
|
||||||
state_changes=[{"type": "attack", "name": attack.name, "damage": base_damage}],
|
state_changes=state_changes,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _execute_retreat(
|
def _execute_retreat(
|
||||||
@ -743,7 +812,9 @@ class GameEngine:
|
|||||||
|
|
||||||
# Discard energy for retreat cost (simplified - assume cost already validated)
|
# Discard energy for retreat cost (simplified - assume cost already validated)
|
||||||
for energy_id in action.energy_to_discard:
|
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
|
# Swap positions
|
||||||
player.active.remove(active.instance_id)
|
player.active.remove(active.instance_id)
|
||||||
|
|||||||
@ -299,8 +299,14 @@ class CardInstance(BaseModel):
|
|||||||
instance_id: Unique identifier for this specific instance (UUID).
|
instance_id: Unique identifier for this specific instance (UUID).
|
||||||
definition_id: Reference to the CardDefinition.id.
|
definition_id: Reference to the CardDefinition.id.
|
||||||
damage: Current damage on this card (Pokemon only).
|
damage: Current damage on this card (Pokemon only).
|
||||||
attached_energy: List of CardInstance IDs for attached energy cards.
|
attached_energy: Energy cards attached to this Pokemon. These CardInstance
|
||||||
attached_tools: List of CardInstance IDs for attached tool cards.
|
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.
|
status_conditions: Active status conditions on this Pokemon.
|
||||||
ability_uses_this_turn: Number of times abilities have been used this turn.
|
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.
|
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.
|
attack index is not in this dict, the base cost from CardDefinition is used.
|
||||||
Example: {0: [EnergyType.COLORLESS, EnergyType.COLORLESS]} makes the first
|
Example: {0: [EnergyType.COLORLESS, EnergyType.COLORLESS]} makes the first
|
||||||
attack cost 2 colorless instead of its normal cost.
|
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.
|
turn_played: The turn number when this card was played/evolved.
|
||||||
Used for evolution timing rules.
|
Used for evolution timing rules.
|
||||||
turn_evolved: The turn number when this card last evolved.
|
turn_evolved: The turn number when this card last evolved.
|
||||||
@ -328,8 +335,9 @@ class CardInstance(BaseModel):
|
|||||||
|
|
||||||
# Battle state (Pokemon only)
|
# Battle state (Pokemon only)
|
||||||
damage: int = 0
|
damage: int = 0
|
||||||
attached_energy: list[str] = Field(default_factory=list)
|
attached_energy: list["CardInstance"] = Field(default_factory=list)
|
||||||
attached_tools: list[str] = 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)
|
status_conditions: list[StatusCondition] = Field(default_factory=list)
|
||||||
ability_uses_this_turn: int = 0
|
ability_uses_this_turn: int = 0
|
||||||
|
|
||||||
@ -483,24 +491,65 @@ class CardInstance(BaseModel):
|
|||||||
"""Get the number of attached energy cards."""
|
"""Get the number of attached energy cards."""
|
||||||
return len(self.attached_energy)
|
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.
|
"""Attach an energy card to this Pokemon.
|
||||||
|
|
||||||
Args:
|
The energy CardInstance is stored directly on this Pokemon, not in any zone.
|
||||||
energy_instance_id: The CardInstance.instance_id of the energy card.
|
When the Pokemon is knocked out, attached energy goes to the owner's discard.
|
||||||
"""
|
|
||||||
self.attached_energy.append(energy_instance_id)
|
|
||||||
|
|
||||||
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.
|
"""Detach an energy card from this Pokemon.
|
||||||
|
|
||||||
Args:
|
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:
|
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:
|
for i, energy in enumerate(self.attached_energy):
|
||||||
self.attached_energy.remove(energy_instance_id)
|
if energy.instance_id == energy_instance_id:
|
||||||
return True
|
return self.attached_energy.pop(i)
|
||||||
return False
|
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]:
|
def find_card_instance(self, instance_id: str) -> tuple[CardInstance | None, str | None]:
|
||||||
"""Find a CardInstance anywhere in the game.
|
"""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:
|
Args:
|
||||||
instance_id: The instance_id to search for.
|
instance_id: The instance_id to search for.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (CardInstance, zone_type) if found, (None, None) if not.
|
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():
|
for player in self.players.values():
|
||||||
|
# Search standard zones
|
||||||
for zone_name in [
|
for zone_name in [
|
||||||
"deck",
|
"deck",
|
||||||
"hand",
|
"hand",
|
||||||
@ -538,9 +544,26 @@ class GameState(BaseModel):
|
|||||||
"discard",
|
"discard",
|
||||||
"prizes",
|
"prizes",
|
||||||
"energy_deck",
|
"energy_deck",
|
||||||
|
"energy_zone",
|
||||||
]:
|
]:
|
||||||
zone: Zone = getattr(player, zone_name)
|
zone: Zone = getattr(player, zone_name)
|
||||||
card = zone.get(instance_id)
|
card = zone.get(instance_id)
|
||||||
if card:
|
if card:
|
||||||
return card, zone_name
|
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
|
return None, None
|
||||||
|
|||||||
@ -251,14 +251,10 @@ def _get_attached_energy_types(game: GameState, pokemon: CardInstance) -> list[E
|
|||||||
"""
|
"""
|
||||||
energy_types: list[EnergyType] = []
|
energy_types: list[EnergyType] = []
|
||||||
|
|
||||||
for energy_id in pokemon.attached_energy:
|
# Energy cards are now CardInstance objects stored directly on the Pokemon
|
||||||
# Find the energy card instance
|
for energy_card in pokemon.attached_energy:
|
||||||
card_instance, _ = game.find_card_instance(energy_id)
|
|
||||||
if card_instance is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get the definition
|
# 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:
|
if definition is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -851,8 +847,10 @@ def _validate_retreat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check that all energy to discard is actually attached
|
# 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:
|
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(
|
return ValidationResult(
|
||||||
valid=False,
|
valid=False,
|
||||||
reason=f"Energy {energy_id} is not attached to active Pokemon",
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.models.enums import GameEndReason, StatusCondition, TurnPhase
|
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:
|
if TYPE_CHECKING:
|
||||||
from app.core.models.game_state import GameState
|
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
|
1. Applies poison/burn damage to the current player's active Pokemon
|
||||||
2. Checks for status recovery (burn flip, sleep flip)
|
2. Checks for status recovery (burn flip, sleep flip)
|
||||||
3. Removes paralysis (wears off after one turn)
|
3. Removes paralysis (wears off after one turn)
|
||||||
4. Checks for knockouts from status damage
|
4. Processes knockouts from status damage (moves Pokemon to discard, awards points)
|
||||||
5. Advances to the next player's turn
|
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.
|
Call this when the current player's turn should end.
|
||||||
|
|
||||||
@ -400,13 +401,16 @@ class TurnManager:
|
|||||||
knockouts.append(active.instance_id)
|
knockouts.append(active.instance_id)
|
||||||
messages.append(f"{card_def.name} knocked out by status damage!")
|
messages.append(f"{card_def.name} knocked out by status damage!")
|
||||||
|
|
||||||
# Check for win conditions after status knockouts
|
# Process knockouts BEFORE checking win conditions
|
||||||
# Handle knockout - move Pokemon to discard, check for game end
|
# The opponent (who will be the next player) scores points for status KOs
|
||||||
# Note: The actual knockout handling (scoring, forced active selection)
|
|
||||||
# should be handled by the game engine. We just report the knockout here.
|
|
||||||
win_result = None
|
win_result = None
|
||||||
if knockouts and rules.win_conditions.no_pokemon_in_play:
|
opponent_id = game.get_opponent_id(player.player_id)
|
||||||
win_result = check_no_pokemon_in_play(game)
|
|
||||||
|
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
|
# Advance to next player's turn
|
||||||
game.advance_turn()
|
game.advance_turn()
|
||||||
@ -445,8 +449,28 @@ class TurnManager:
|
|||||||
for player_id, player in game.players.items():
|
for player_id, player in game.players.items():
|
||||||
card = player.active.get(knocked_out_id)
|
card = player.active.get(knocked_out_id)
|
||||||
if card:
|
if card:
|
||||||
# Found in active - remove and discard
|
# Found in active - remove from zone
|
||||||
player.active.remove(knocked_out_id)
|
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)
|
player.discard.add(card)
|
||||||
|
|
||||||
# Award points to opponent
|
# Award points to opponent
|
||||||
@ -490,7 +514,27 @@ class TurnManager:
|
|||||||
# Check bench too (for bench damage knockouts)
|
# Check bench too (for bench damage knockouts)
|
||||||
card = player.bench.get(knocked_out_id)
|
card = player.bench.get(knocked_out_id)
|
||||||
if card:
|
if card:
|
||||||
|
# Remove from bench
|
||||||
player.bench.remove(knocked_out_id)
|
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)
|
player.discard.add(card)
|
||||||
|
|
||||||
# Award points
|
# 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
|
# Player 1 setup - active with energy, bench pokemon, cards in hand
|
||||||
pikachu = card_instance_factory("pikachu_base_001", turn_played=1)
|
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.active.add(pikachu)
|
||||||
player1.bench.add(card_instance_factory("charmander_base_001", turn_played=1))
|
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
|
# 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("raichu_base_001", instance_id="hand_raichu"))
|
||||||
player1.hand.add(card_instance_factory("charmeleon_base_001", instance_id="hand_charmeleon"))
|
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)
|
# Player 1 - Pikachu with 1 lightning energy (enough for Thunder Shock)
|
||||||
pikachu = card_instance_factory("pikachu_base_001", turn_played=1)
|
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.active.add(pikachu)
|
||||||
player1.bench.add(card_instance_factory("charmander_base_001", turn_played=1))
|
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):
|
for i in range(10):
|
||||||
player1.deck.add(card_instance_factory("pikachu_base_001", instance_id=f"deck_{i}"))
|
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:
|
def test_discards_energy_from_source(self, game_state: GameState, rng: SeededRandom) -> None:
|
||||||
"""
|
"""
|
||||||
Verify discard_energy removes energy from source Pokemon by default.
|
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()
|
source = game_state.players["player1"].get_active_pokemon()
|
||||||
assert source is not None
|
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})
|
ctx = make_context(game_state, rng, params={"count": 2})
|
||||||
|
|
||||||
@ -1354,14 +1364,24 @@ class TestDiscardEnergy:
|
|||||||
assert result.effect_type == EffectType.ENERGY
|
assert result.effect_type == EffectType.ENERGY
|
||||||
assert len(source.attached_energy) == 1
|
assert len(source.attached_energy) == 1
|
||||||
assert result.details["count"] == 2
|
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:
|
def test_discards_specific_energy(self, game_state: GameState, rng: SeededRandom) -> None:
|
||||||
"""
|
"""
|
||||||
Verify discard_energy can discard specific energy cards.
|
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()
|
source = game_state.players["player1"].get_active_pokemon()
|
||||||
assert source is not None
|
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(
|
ctx = make_context(
|
||||||
game_state,
|
game_state,
|
||||||
@ -1372,7 +1392,9 @@ class TestDiscardEnergy:
|
|||||||
result = resolve_effect("discard_energy", ctx)
|
result = resolve_effect("discard_energy", ctx)
|
||||||
|
|
||||||
assert result.success
|
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:
|
def test_fails_with_no_energy(self, game_state: GameState, rng: SeededRandom) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -471,9 +471,9 @@ class TestActionExecution:
|
|||||||
assert result.success
|
assert result.success
|
||||||
assert "Energy attached" in result.message
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_execute_attack(
|
async def test_execute_attack(
|
||||||
@ -485,12 +485,10 @@ class TestActionExecution:
|
|||||||
"""
|
"""
|
||||||
Test executing an attack action.
|
Test executing an attack action.
|
||||||
"""
|
"""
|
||||||
# Attach energy - the energy must be in a zone so find_card_instance works
|
# Attach energy - energy CardInstance is stored directly on the Pokemon
|
||||||
# Put it in discard pile (energy stays there after being attached for tracking)
|
|
||||||
p1 = ready_game.players["player1"]
|
p1 = ready_game.players["player1"]
|
||||||
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
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)
|
||||||
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
|
||||||
|
|
||||||
# Need to be in ATTACK phase for attack action
|
# Need to be in ATTACK phase for attack action
|
||||||
ready_game.phase = TurnPhase.ATTACK
|
ready_game.phase = TurnPhase.ATTACK
|
||||||
@ -553,9 +551,10 @@ class TestActionExecution:
|
|||||||
"""
|
"""
|
||||||
Test executing a retreat action.
|
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 = 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(
|
action = RetreatAction(
|
||||||
new_active_id="p1-bench-1",
|
new_active_id="p1-bench-1",
|
||||||
@ -713,8 +712,8 @@ class TestWinConditions:
|
|||||||
# This gives player1 their 4th point, winning the game
|
# This gives player1 their 4th point, winning the game
|
||||||
p1 = near_win_game.players["player1"]
|
p1 = near_win_game.players["player1"]
|
||||||
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
||||||
p1.discard.add(energy) # Must be findable
|
# Energy CardInstance is now stored directly on the Pokemon
|
||||||
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
p1.get_active_pokemon().attach_energy(energy)
|
||||||
|
|
||||||
# Need to be in ATTACK phase
|
# Need to be in ATTACK phase
|
||||||
near_win_game.phase = TurnPhase.ATTACK
|
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 = CardInstance(instance_id="active-pikachu", definition_id=basic_pokemon_def.id)
|
||||||
active.turn_played = 1 # Played last turn, can evolve
|
active.turn_played = 1 # Played last turn, can evolve
|
||||||
active.damage = 20
|
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)
|
p1.active.add(active)
|
||||||
|
|
||||||
# Player 1: Raichu in hand
|
# Player 1: Raichu in hand
|
||||||
@ -1166,11 +1167,11 @@ class TestEvolvePokemonAction:
|
|||||||
assert active.definition_id == "raichu-evo"
|
assert active.definition_id == "raichu-evo"
|
||||||
|
|
||||||
# Verify energy and damage transferred
|
# 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
|
assert active.damage == 20
|
||||||
|
|
||||||
# Verify old Pokemon went to discard
|
# Verify old Pokemon is in evolution stack (cards_underneath), not discard
|
||||||
assert "active-pikachu" in game.players["player1"].discard
|
assert any(c.instance_id == "active-pikachu" for c in active.cards_underneath)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_evolve_pokemon_not_in_hand(
|
async def test_evolve_pokemon_not_in_hand(
|
||||||
@ -1856,10 +1857,10 @@ class TestAttachEnergyFromEnergyZone:
|
|||||||
|
|
||||||
assert result.success
|
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"]
|
p1 = game_with_energy_zone.players["player1"]
|
||||||
active = p1.get_active_pokemon()
|
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
|
# Energy zone should be empty
|
||||||
assert "zone-energy" not in p1.energy_zone
|
assert "zone-energy" not in p1.energy_zone
|
||||||
@ -1887,9 +1888,9 @@ class TestAttachEnergyFromEnergyZone:
|
|||||||
|
|
||||||
assert result.success
|
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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_attach_energy_target_not_found(
|
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.
|
Verify energy can be attached to a Pokemon.
|
||||||
"""
|
"""
|
||||||
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
|
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(energy1)
|
||||||
instance.attach_energy("energy-002")
|
instance.attach_energy(energy2)
|
||||||
|
|
||||||
assert instance.energy_count() == 2
|
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:
|
def test_detach_energy(self) -> None:
|
||||||
"""
|
"""
|
||||||
Verify energy can be detached from a Pokemon.
|
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 = CardInstance(instance_id="uuid", definition_id="pikachu")
|
||||||
instance.attach_energy("energy-001")
|
energy1 = CardInstance(instance_id="energy-001", definition_id="fire_energy")
|
||||||
instance.attach_energy("energy-002")
|
energy2 = CardInstance(instance_id="energy-002", definition_id="fire_energy")
|
||||||
|
instance.attach_energy(energy1)
|
||||||
|
instance.attach_energy(energy2)
|
||||||
|
|
||||||
result = instance.detach_energy("energy-001")
|
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 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:
|
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")
|
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
|
||||||
|
|
||||||
result = instance.detach_energy("nonexistent")
|
result = instance.detach_energy("nonexistent")
|
||||||
|
|
||||||
assert result is False
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestCardInstanceTurnState:
|
class TestCardInstanceTurnState:
|
||||||
@ -1424,12 +1431,16 @@ class TestCardInstanceJsonRoundTrip:
|
|||||||
def test_round_trip(self) -> None:
|
def test_round_trip(self) -> None:
|
||||||
"""
|
"""
|
||||||
Verify CardInstance round-trips through JSON.
|
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(
|
original = CardInstance(
|
||||||
instance_id="uuid-12345",
|
instance_id="uuid-12345",
|
||||||
definition_id="pikachu_base_001",
|
definition_id="pikachu_base_001",
|
||||||
damage=30,
|
damage=30,
|
||||||
attached_energy=["energy-001", "energy-002"],
|
attached_energy=[energy1, energy2],
|
||||||
status_conditions=[StatusCondition.POISONED],
|
status_conditions=[StatusCondition.POISONED],
|
||||||
turn_played=2,
|
turn_played=2,
|
||||||
)
|
)
|
||||||
@ -1440,4 +1451,5 @@ class TestCardInstanceJsonRoundTrip:
|
|||||||
assert restored.instance_id == original.instance_id
|
assert restored.instance_id == original.instance_id
|
||||||
assert restored.damage == 30
|
assert restored.damage == 30
|
||||||
assert len(restored.attached_energy) == 2
|
assert len(restored.attached_energy) == 2
|
||||||
|
assert restored.attached_energy[0].instance_id == "energy-001"
|
||||||
assert StatusCondition.POISONED in restored.status_conditions
|
assert StatusCondition.POISONED in restored.status_conditions
|
||||||
|
|||||||
@ -1243,39 +1243,23 @@ class TestAttackValidation:
|
|||||||
Verify that Colorless energy can be satisfied by any type.
|
Verify that Colorless energy can be satisfied by any type.
|
||||||
|
|
||||||
Add a Pokemon with Colorless cost and attach Fire energy.
|
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"]
|
player = game_in_attack_phase.players["player1"]
|
||||||
|
|
||||||
# Replace active with Raichu (costs: Lightning, Lightning, Colorless)
|
# Replace active with Raichu (costs: Lightning, Lightning, Colorless)
|
||||||
player.active.clear()
|
player.active.clear()
|
||||||
raichu = card_instance_factory("raichu_base_001", instance_id="test_raichu")
|
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)
|
# Attach 2 Lightning + 1 Fire (Fire satisfies Colorless)
|
||||||
raichu.attach_energy("energy_1")
|
raichu.attach_energy(energy1)
|
||||||
raichu.attach_energy("energy_2")
|
raichu.attach_energy(energy2)
|
||||||
raichu.attach_energy("energy_3")
|
raichu.attach_energy(energy3)
|
||||||
player.active.add(raichu)
|
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)
|
action = AttackAction(attack_index=0)
|
||||||
result = validate_action(game_in_attack_phase, "player1", action)
|
result = validate_action(game_in_attack_phase, "player1", action)
|
||||||
|
|
||||||
@ -1391,13 +1375,12 @@ class TestAttackValidation:
|
|||||||
# Add energy to active
|
# Add energy to active
|
||||||
player = game_first_turn.players["player1"]
|
player = game_first_turn.players["player1"]
|
||||||
pikachu = player.active.cards[0]
|
pikachu = player.active.cards[0]
|
||||||
pikachu.attach_energy("test_energy")
|
# Attach energy as a CardInstance
|
||||||
player.discard.add(
|
energy = CardInstance(
|
||||||
CardInstance(
|
instance_id="test_energy",
|
||||||
instance_id="test_energy",
|
definition_id="lightning_energy_001",
|
||||||
definition_id="lightning_energy_001",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
pikachu.attach_energy(energy)
|
||||||
|
|
||||||
action = AttackAction(attack_index=0)
|
action = AttackAction(attack_index=0)
|
||||||
result = validate_action(game_first_turn, "player1", action)
|
result = validate_action(game_first_turn, "player1", action)
|
||||||
@ -1413,16 +1396,14 @@ class TestAttackValidation:
|
|||||||
"""
|
"""
|
||||||
game_first_turn.phase = TurnPhase.ATTACK
|
game_first_turn.phase = TurnPhase.ATTACK
|
||||||
|
|
||||||
# Add energy to active
|
# Add energy to active as CardInstance
|
||||||
player = game_first_turn.players["player1"]
|
player = game_first_turn.players["player1"]
|
||||||
pikachu = player.active.cards[0]
|
pikachu = player.active.cards[0]
|
||||||
pikachu.attach_energy("test_energy")
|
energy = CardInstance(
|
||||||
player.discard.add(
|
instance_id="test_energy",
|
||||||
CardInstance(
|
definition_id="lightning_energy_001",
|
||||||
instance_id="test_energy",
|
|
||||||
definition_id="lightning_energy_001",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
pikachu.attach_energy(energy)
|
||||||
|
|
||||||
action = AttackAction(attack_index=0)
|
action = AttackAction(attack_index=0)
|
||||||
result = validate_action(game_first_turn, "player1", action)
|
result = validate_action(game_first_turn, "player1", action)
|
||||||
@ -1888,11 +1869,9 @@ class TestEnergyCostCalculation:
|
|||||||
player = game_in_attack_phase.players["player1"]
|
player = game_in_attack_phase.players["player1"]
|
||||||
pikachu = player.active.cards[0]
|
pikachu = player.active.cards[0]
|
||||||
|
|
||||||
# Add extra energy
|
# Add extra energy as CardInstance
|
||||||
pikachu.attach_energy("extra_energy")
|
extra_energy = card_instance_factory("lightning_energy_001", instance_id="extra_energy")
|
||||||
player.discard.add(
|
pikachu.attach_energy(extra_energy)
|
||||||
card_instance_factory("lightning_energy_001", instance_id="extra_energy")
|
|
||||||
)
|
|
||||||
|
|
||||||
action = AttackAction(attack_index=0)
|
action = AttackAction(attack_index=0)
|
||||||
result = validate_action(game_in_attack_phase, "player1", action)
|
result = validate_action(game_in_attack_phase, "player1", action)
|
||||||
@ -1910,10 +1889,10 @@ class TestEnergyCostCalculation:
|
|||||||
player = game_in_attack_phase.players["player1"]
|
player = game_in_attack_phase.players["player1"]
|
||||||
pikachu = player.active.cards[0]
|
pikachu = player.active.cards[0]
|
||||||
|
|
||||||
# Replace Lightning with Fire
|
# Replace Lightning with Fire as CardInstance
|
||||||
pikachu.attached_energy.clear()
|
pikachu.attached_energy.clear()
|
||||||
pikachu.attach_energy("fire_energy")
|
fire_energy = card_instance_factory("fire_energy_001", instance_id="fire_energy")
|
||||||
player.discard.add(card_instance_factory("fire_energy_001", instance_id="fire_energy"))
|
pikachu.attach_energy(fire_energy)
|
||||||
|
|
||||||
action = AttackAction(attack_index=0)
|
action = AttackAction(attack_index=0)
|
||||||
result = validate_action(game_in_attack_phase, "player1", action)
|
result = validate_action(game_in_attack_phase, "player1", action)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user