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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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