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:
Cal Corum 2026-01-25 23:09:40 -06:00
parent c3ab03c691
commit 2b8fac405f
13 changed files with 1899 additions and 147 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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