From 2b8fac405fb103fb3b1c29dde0947e5d59319af7 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 25 Jan 2026 23:09:40 -0600 Subject: [PATCH] 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. --- backend/PROJECT_PLAN_ENERGY_EVOLUTION.md | 257 ++++ backend/app/core/effects/handlers.py | 151 ++- backend/app/core/engine.py | 97 +- backend/app/core/models/card.py | 83 +- backend/app/core/models/game_state.py | 25 +- backend/app/core/rules_validator.py | 14 +- backend/app/core/turn_manager.py | 64 +- backend/tests/core/conftest.py | 20 +- .../tests/core/test_effects/test_handlers.py | 28 +- backend/tests/core/test_engine.py | 37 +- backend/tests/core/test_evolution_stack.py | 1169 +++++++++++++++++ backend/tests/core/test_models/test_card.py | 32 +- backend/tests/core/test_rules_validator.py | 69 +- 13 files changed, 1899 insertions(+), 147 deletions(-) create mode 100644 backend/PROJECT_PLAN_ENERGY_EVOLUTION.md create mode 100644 backend/tests/core/test_evolution_stack.py diff --git a/backend/PROJECT_PLAN_ENERGY_EVOLUTION.md b/backend/PROJECT_PLAN_ENERGY_EVOLUTION.md new file mode 100644 index 0000000..140c50a --- /dev/null +++ b/backend/PROJECT_PLAN_ENERGY_EVOLUTION.md @@ -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 diff --git a/backend/app/core/effects/handlers.py b/backend/app/core/effects/handlers.py index 3102c71..3d6cd71 100644 --- a/backend/app/core/effects/handlers.py +++ b/backend/app/core/effects/handlers.py @@ -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. diff --git a/backend/app/core/engine.py b/backend/app/core/engine.py index 54b1498..4f7131c 100644 --- a/backend/app/core/engine.py +++ b/backend/app/core/engine.py @@ -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) diff --git a/backend/app/core/models/card.py b/backend/app/core/models/card.py index 2d8bd12..7607982 100644 --- a/backend/app/core/models/card.py +++ b/backend/app/core/models/card.py @@ -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() diff --git a/backend/app/core/models/game_state.py b/backend/app/core/models/game_state.py index 27324c0..02b951d 100644 --- a/backend/app/core/models/game_state.py +++ b/backend/app/core/models/game_state.py @@ -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 diff --git a/backend/app/core/rules_validator.py b/backend/app/core/rules_validator.py index d0d853b..12b3074 100644 --- a/backend/app/core/rules_validator.py +++ b/backend/app/core/rules_validator.py @@ -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", diff --git a/backend/app/core/turn_manager.py b/backend/app/core/turn_manager.py index ada9c25..2138513 100644 --- a/backend/app/core/turn_manager.py +++ b/backend/app/core/turn_manager.py @@ -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 diff --git a/backend/tests/core/conftest.py b/backend/tests/core/conftest.py index 2d2211e..b891e69 100644 --- a/backend/tests/core/conftest.py +++ b/backend/tests/core/conftest.py @@ -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}")) diff --git a/backend/tests/core/test_effects/test_handlers.py b/backend/tests/core/test_effects/test_handlers.py index 5e16676..98261ad 100644 --- a/backend/tests/core/test_effects/test_handlers.py +++ b/backend/tests/core/test_effects/test_handlers.py @@ -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: """ diff --git a/backend/tests/core/test_engine.py b/backend/tests/core/test_engine.py index 28ea4a5..54ac6a1 100644 --- a/backend/tests/core/test_engine.py +++ b/backend/tests/core/test_engine.py @@ -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( diff --git a/backend/tests/core/test_evolution_stack.py b/backend/tests/core/test_evolution_stack.py new file mode 100644 index 0000000..a53adc6 --- /dev/null +++ b/backend/tests/core/test_evolution_stack.py @@ -0,0 +1,1169 @@ +"""Tests for the evolution stack and attached cards mechanics. + +This module tests the new CardInstance fields and their integration: +- cards_underneath: Evolution stack tracking (Basic -> Stage 1 -> Stage 2) +- attached_energy: Energy cards as CardInstance objects stored on Pokemon +- attached_tools: Tool cards as CardInstance objects stored on Pokemon + +Also tests the devolve effect handler and knockout processing with attachments. + +Test categories: + - Evolution stack building (evolve mechanics) + - Energy/tool attachment and transfer during evolution + - Damage carryover on evolution + - Devolve effect handler + - Knockout processing with attached cards + - find_card_instance for cards in attachments/stack +""" + +import pytest + +# Import handlers to register them +import app.core.effects.handlers # noqa: F401 +from app.core.config import RulesConfig +from app.core.effects.base import EffectContext +from app.core.effects.registry import resolve_effect +from app.core.engine import GameEngine +from app.core.models.actions import EvolvePokemonAction +from app.core.models.card import Attack, CardDefinition, CardInstance +from app.core.models.enums import ( + CardType, + EnergyType, + PokemonStage, + PokemonVariant, + StatusCondition, + TurnPhase, +) +from app.core.models.game_state import GameState, PlayerState +from app.core.rng import SeededRandom +from app.core.turn_manager import TurnManager + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def basic_pokemon_def() -> CardDefinition: + """Basic Pokemon (Charmander) - can evolve to Stage 1.""" + return CardDefinition( + id="charmander-001", + name="Charmander", + card_type=CardType.POKEMON, + stage=PokemonStage.BASIC, + variant=PokemonVariant.NORMAL, + hp=50, + pokemon_type=EnergyType.FIRE, + retreat_cost=1, + ) + + +@pytest.fixture +def stage1_pokemon_def() -> CardDefinition: + """Stage 1 Pokemon (Charmeleon) - evolves from Basic, can evolve to Stage 2.""" + return CardDefinition( + id="charmeleon-001", + name="Charmeleon", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_1, + variant=PokemonVariant.NORMAL, + evolves_from="Charmander", # Uses name, not ID + hp=80, + pokemon_type=EnergyType.FIRE, + retreat_cost=2, + attacks=[ + Attack( + name="Slash", + damage=30, + cost=[EnergyType.FIRE], + ), + ], + ) + + +@pytest.fixture +def stage2_pokemon_def() -> CardDefinition: + """Stage 2 Pokemon (Charizard) - evolves from Stage 1.""" + return CardDefinition( + id="charizard-001", + name="Charizard", + card_type=CardType.POKEMON, + stage=PokemonStage.STAGE_2, + variant=PokemonVariant.NORMAL, + evolves_from="Charmeleon", # Uses name, not ID + hp=150, + pokemon_type=EnergyType.FIRE, + retreat_cost=3, + attacks=[ + Attack( + name="Fire Spin", + damage=120, + cost=[EnergyType.FIRE, EnergyType.FIRE, EnergyType.FIRE], + ), + ], + ) + + +@pytest.fixture +def fire_energy_def() -> CardDefinition: + """Fire energy card definition.""" + return CardDefinition( + id="fire-energy-001", + name="Fire Energy", + card_type=CardType.ENERGY, + energy_type=EnergyType.FIRE, + energy_provides=[EnergyType.FIRE], + ) + + +@pytest.fixture +def tool_def() -> CardDefinition: + """Pokemon Tool card definition.""" + return CardDefinition( + id="choice-band-001", + name="Choice Band", + card_type=CardType.TRAINER, + trainer_type="tool", + effect_id="modify_damage", + effect_params={"amount": 30, "condition": "vs_gx_ex"}, + effect_description="+30 damage against GX/EX", + ) + + +@pytest.fixture +def game_state( + basic_pokemon_def: CardDefinition, + stage1_pokemon_def: CardDefinition, + stage2_pokemon_def: CardDefinition, + fire_energy_def: CardDefinition, + tool_def: CardDefinition, +) -> GameState: + """Create a game state set up for evolution testing. + + Player 1 has: + - Active: Charmander (Basic, played last turn) + - Hand: Charmeleon, Charizard, 2x Fire Energy + - Deck: 5 cards + + Player 2 has: + - Active: Charmander + """ + game = GameState( + game_id="test-evolution", + rules=RulesConfig(), + card_registry={ + basic_pokemon_def.id: basic_pokemon_def, + stage1_pokemon_def.id: stage1_pokemon_def, + stage2_pokemon_def.id: stage2_pokemon_def, + fire_energy_def.id: fire_energy_def, + tool_def.id: tool_def, + }, + players={ + "player1": PlayerState(player_id="player1"), + "player2": PlayerState(player_id="player2"), + }, + turn_order=["player1", "player2"], + current_player_id="player1", + turn_number=2, # Turn 2 so evolution is allowed + phase=TurnPhase.MAIN, + first_turn_completed=True, + ) + + # Player 1 setup + p1 = game.players["player1"] + + # Active Charmander (played on turn 1) + charmander = CardInstance( + instance_id="p1-charmander", + definition_id=basic_pokemon_def.id, + turn_played=1, + ) + p1.active.add(charmander) + + # Evolution cards in hand + charmeleon = CardInstance(instance_id="p1-charmeleon", definition_id=stage1_pokemon_def.id) + charizard = CardInstance(instance_id="p1-charizard", definition_id=stage2_pokemon_def.id) + p1.hand.add(charmeleon) + p1.hand.add(charizard) + + # Energy cards in hand + for i in range(2): + energy = CardInstance(instance_id=f"p1-energy-{i}", definition_id=fire_energy_def.id) + p1.hand.add(energy) + + # Tool in hand + tool = CardInstance(instance_id="p1-tool", definition_id=tool_def.id) + p1.hand.add(tool) + + # Deck + for i in range(5): + card = CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id) + p1.deck.add(card) + + # Player 2 setup - simple active + p2 = game.players["player2"] + p2_charmander = CardInstance( + instance_id="p2-charmander", + definition_id=basic_pokemon_def.id, + turn_played=1, + ) + p2.active.add(p2_charmander) + + for i in range(5): + card = CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id) + p2.deck.add(card) + + return game + + +@pytest.fixture +def seeded_rng() -> SeededRandom: + """Create a seeded RNG for deterministic tests.""" + return SeededRandom(seed=42) + + +@pytest.fixture +def engine(seeded_rng: SeededRandom) -> GameEngine: + """Create a game engine with seeded RNG.""" + return GameEngine(rng=seeded_rng) + + +# ============================================================================= +# Evolution Stack Tests +# ============================================================================= + + +class TestEvolutionStack: + """Tests for the evolution stack (cards_underneath) mechanics.""" + + @pytest.mark.asyncio + async def test_basic_to_stage1_creates_stack( + self, engine: GameEngine, game_state: GameState + ) -> None: + """ + Test that evolving Basic to Stage 1 creates correct evolution stack. + + When Charmander evolves to Charmeleon, the Charmander CardInstance + should be stored in Charmeleon's cards_underneath list. + """ + action = EvolvePokemonAction( + evolution_card_id="p1-charmeleon", + target_pokemon_id="p1-charmander", + ) + + result = await engine.execute_action(game_state, "player1", action) + + assert result.success, f"Evolution failed: {result.message}" + + # Get the evolved Pokemon (now Charmeleon) + active = game_state.players["player1"].get_active_pokemon() + assert active is not None + assert active.definition_id == "charmeleon-001" + assert active.instance_id == "p1-charmeleon" + + # Check evolution stack contains the Basic + assert len(active.cards_underneath) == 1 + basic_in_stack = active.cards_underneath[0] + assert basic_in_stack.instance_id == "p1-charmander" + assert basic_in_stack.definition_id == "charmander-001" + + @pytest.mark.asyncio + async def test_stage1_to_stage2_preserves_full_stack( + self, engine: GameEngine, game_state: GameState + ) -> None: + """ + Test that evolving Stage 1 to Stage 2 preserves full evolution stack. + + After Charmander -> Charmeleon -> Charizard, the Charizard should have + both Charmander and Charmeleon in its cards_underneath list in order. + """ + # First evolution: Basic -> Stage 1 + action1 = EvolvePokemonAction( + evolution_card_id="p1-charmeleon", + target_pokemon_id="p1-charmander", + ) + result1 = await engine.execute_action(game_state, "player1", action1) + assert result1.success + + # Advance turn to allow second evolution (can't evolve same turn) + game_state.turn_number = 3 + charmeleon = game_state.players["player1"].get_active_pokemon() + charmeleon.turn_played = 2 # Played last turn + + # Second evolution: Stage 1 -> Stage 2 + action2 = EvolvePokemonAction( + evolution_card_id="p1-charizard", + target_pokemon_id="p1-charmeleon", + ) + result2 = await engine.execute_action(game_state, "player1", action2) + assert result2.success, f"Second evolution failed: {result2.message}" + + # Get the final evolved Pokemon (Charizard) + active = game_state.players["player1"].get_active_pokemon() + assert active is not None + assert active.definition_id == "charizard-001" + + # Check evolution stack has both previous stages + assert len(active.cards_underneath) == 2 + + # Index 0 should be the oldest (Basic) + assert active.cards_underneath[0].instance_id == "p1-charmander" + assert active.cards_underneath[0].definition_id == "charmander-001" + + # Index 1 should be the more recent (Stage 1) + assert active.cards_underneath[1].instance_id == "p1-charmeleon" + assert active.cards_underneath[1].definition_id == "charmeleon-001" + + @pytest.mark.asyncio + async def test_energy_transfers_on_evolution( + self, engine: GameEngine, game_state: GameState, fire_energy_def: CardDefinition + ) -> None: + """ + Test that attached energy transfers to the evolved Pokemon. + + Energy attached to Charmander should transfer to Charmeleon when + Charmander evolves. The energy CardInstance objects should be preserved. + """ + p1 = game_state.players["player1"] + charmander = p1.get_active_pokemon() + + # Attach energy to Charmander before evolution + energy1 = CardInstance(instance_id="attached-energy-1", definition_id=fire_energy_def.id) + energy2 = CardInstance(instance_id="attached-energy-2", definition_id=fire_energy_def.id) + charmander.attach_energy(energy1) + charmander.attach_energy(energy2) + + assert len(charmander.attached_energy) == 2 + + # Evolve + action = EvolvePokemonAction( + evolution_card_id="p1-charmeleon", + target_pokemon_id="p1-charmander", + ) + result = await engine.execute_action(game_state, "player1", action) + assert result.success + + # Get evolved Pokemon + charmeleon = p1.get_active_pokemon() + + # Energy should have transferred + assert len(charmeleon.attached_energy) == 2 + energy_ids = [e.instance_id for e in charmeleon.attached_energy] + assert "attached-energy-1" in energy_ids + assert "attached-energy-2" in energy_ids + + @pytest.mark.asyncio + async def test_tools_transfer_on_evolution( + self, engine: GameEngine, game_state: GameState, tool_def: CardDefinition + ) -> None: + """ + Test that attached tools transfer to the evolved Pokemon. + + Tools attached to the Basic should transfer to the evolved Pokemon. + """ + p1 = game_state.players["player1"] + charmander = p1.get_active_pokemon() + + # Attach tool to Charmander + tool = CardInstance(instance_id="attached-tool", definition_id=tool_def.id) + charmander.attach_tool(tool) + + assert len(charmander.attached_tools) == 1 + + # Evolve + action = EvolvePokemonAction( + evolution_card_id="p1-charmeleon", + target_pokemon_id="p1-charmander", + ) + result = await engine.execute_action(game_state, "player1", action) + assert result.success + + # Get evolved Pokemon + charmeleon = p1.get_active_pokemon() + + # Tool should have transferred + assert len(charmeleon.attached_tools) == 1 + assert charmeleon.attached_tools[0].instance_id == "attached-tool" + + @pytest.mark.asyncio + async def test_damage_carries_over_on_evolution( + self, engine: GameEngine, game_state: GameState + ) -> None: + """ + Test that damage carries over to the evolved Pokemon. + + If Charmander has 30 damage and evolves to Charmeleon (80 HP), + Charmeleon should still have 30 damage. + """ + p1 = game_state.players["player1"] + charmander = p1.get_active_pokemon() + + # Deal damage to Charmander + charmander.damage = 30 + + # Evolve + action = EvolvePokemonAction( + evolution_card_id="p1-charmeleon", + target_pokemon_id="p1-charmander", + ) + result = await engine.execute_action(game_state, "player1", action) + assert result.success + + # Get evolved Pokemon + charmeleon = p1.get_active_pokemon() + + # Damage should have carried over + assert charmeleon.damage == 30 + + @pytest.mark.asyncio + async def test_status_conditions_clear_on_evolution( + self, engine: GameEngine, game_state: GameState + ) -> None: + """ + Test that status conditions are cleared when a Pokemon evolves. + + This is standard Pokemon TCG behavior - evolution removes status. + """ + p1 = game_state.players["player1"] + charmander = p1.get_active_pokemon() + + # Apply status conditions + charmander.add_status(StatusCondition.POISONED) + charmander.add_status(StatusCondition.CONFUSED) + + assert len(charmander.status_conditions) == 2 + + # Evolve + action = EvolvePokemonAction( + evolution_card_id="p1-charmeleon", + target_pokemon_id="p1-charmander", + ) + result = await engine.execute_action(game_state, "player1", action) + assert result.success + + # Get evolved Pokemon + charmeleon = p1.get_active_pokemon() + + # Status should be cleared + assert len(charmeleon.status_conditions) == 0 + + +# ============================================================================= +# Devolve Effect Tests +# ============================================================================= + + +class TestDevolveEffect: + """Tests for the devolve effect handler.""" + + @pytest.fixture(autouse=True) + def ensure_handlers_registered(self) -> None: + """Ensure effect handlers are registered before each test. + + This is needed because test_registry.py clears the registry for its tests, + and pytest may run our tests after that cleanup without re-importing. + """ + from app.core.effects.registry import list_effects + + # Only re-import if registry was cleared (devolve not present) + if "devolve" not in list_effects(): + import importlib + + import app.core.effects.handlers + + importlib.reload(app.core.effects.handlers) + + @pytest.fixture + def evolved_game_state( + self, + basic_pokemon_def: CardDefinition, + stage1_pokemon_def: CardDefinition, + stage2_pokemon_def: CardDefinition, + fire_energy_def: CardDefinition, + ) -> GameState: + """Create a game state with an already-evolved Pokemon for devolve testing. + + Player 1 has Charizard active with full evolution stack and attachments. + """ + game = GameState( + game_id="test-devolve", + rules=RulesConfig(), + card_registry={ + basic_pokemon_def.id: basic_pokemon_def, + stage1_pokemon_def.id: stage1_pokemon_def, + stage2_pokemon_def.id: stage2_pokemon_def, + fire_energy_def.id: fire_energy_def, + }, + players={ + "player1": PlayerState(player_id="player1"), + "player2": PlayerState(player_id="player2"), + }, + turn_order=["player1", "player2"], + current_player_id="player2", # Opponent's turn (for devolve effect) + turn_number=5, + phase=TurnPhase.MAIN, + ) + + p1 = game.players["player1"] + + # Build the evolution stack manually for testing + charmander = CardInstance( + instance_id="p1-charmander", + definition_id=basic_pokemon_def.id, + turn_played=1, + ) + charmeleon = CardInstance( + instance_id="p1-charmeleon", + definition_id=stage1_pokemon_def.id, + turn_played=2, + ) + charizard = CardInstance( + instance_id="p1-charizard", + definition_id=stage2_pokemon_def.id, + turn_played=3, + # Evolution stack: [Charmander, Charmeleon] + cards_underneath=[charmander, charmeleon], + ) + + # Attach energy to Charizard + for i in range(3): + energy = CardInstance( + instance_id=f"charizard-energy-{i}", definition_id=fire_energy_def.id + ) + charizard.attach_energy(energy) + + # Add some damage + charizard.damage = 40 + + p1.active.add(charizard) + + # Add cards to deck + for i in range(5): + card = CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id) + p1.deck.add(card) + + # Player 2 setup + p2 = game.players["player2"] + p2_pokemon = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id) + p2.active.add(p2_pokemon) + + for i in range(5): + card = CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id) + p2.deck.add(card) + + return game + + def test_devolve_stage2_to_stage1( + self, evolved_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test devolving a Stage 2 back to Stage 1 (single stage). + + Charizard should become Charmeleon, and Charizard goes to hand. + Energy, damage, and remaining stack should transfer. + """ + ctx = EffectContext( + game=evolved_game_state, + source_player_id="player2", + source_card_id="p2-active", + params={"stages": 1, "destination": "hand"}, + target_card_id="p1-charizard", + rng=seeded_rng, + ) + + result = resolve_effect("devolve", ctx) + + assert result.success + assert "Devolved 1 stage" in result.message + + # Check the active is now Charmeleon + active = evolved_game_state.players["player1"].get_active_pokemon() + assert active is not None + assert active.definition_id == "charmeleon-001" + assert active.instance_id == "p1-charmeleon" + + # Check Charizard went to hand + p1_hand = evolved_game_state.players["player1"].hand + assert "p1-charizard" in p1_hand + + # Check energy transferred to Charmeleon + assert len(active.attached_energy) == 3 + + # Check damage transferred + assert active.damage == 40 + + # Check remaining evolution stack (just Charmander now) + assert len(active.cards_underneath) == 1 + assert active.cards_underneath[0].instance_id == "p1-charmander" + + def test_devolve_stage2_to_basic( + self, evolved_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test devolving a Stage 2 back to Basic (two stages). + + Charizard should become Charmander, and both Charizard and + Charmeleon go to hand. + """ + ctx = EffectContext( + game=evolved_game_state, + source_player_id="player2", + source_card_id="p2-active", + params={"stages": 2, "destination": "hand"}, + target_card_id="p1-charizard", + rng=seeded_rng, + ) + + result = resolve_effect("devolve", ctx) + + assert result.success + assert "Devolved 2 stage" in result.message + + # Check the active is now Charmander (Basic) + active = evolved_game_state.players["player1"].get_active_pokemon() + assert active is not None + assert active.definition_id == "charmander-001" + assert active.instance_id == "p1-charmander" + + # Check both Charizard and Charmeleon went to hand + p1_hand = evolved_game_state.players["player1"].hand + assert "p1-charizard" in p1_hand + assert "p1-charmeleon" in p1_hand + + # Check energy still attached + assert len(active.attached_energy) == 3 + + # Check evolution stack is now empty + assert len(active.cards_underneath) == 0 + + def test_devolve_to_discard( + self, evolved_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test devolving with destination set to discard pile. + + The removed evolution cards should go to the discard pile. + """ + ctx = EffectContext( + game=evolved_game_state, + source_player_id="player2", + source_card_id="p2-active", + params={"stages": 1, "destination": "discard"}, + target_card_id="p1-charizard", + rng=seeded_rng, + ) + + result = resolve_effect("devolve", ctx) + + assert result.success + + # Check Charizard went to discard (not hand) + p1 = evolved_game_state.players["player1"] + assert "p1-charizard" not in p1.hand + assert "p1-charizard" in p1.discard + + def test_devolve_triggers_knockout_if_damage_exceeds_hp( + self, evolved_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that devolving can cause a knockout if damage exceeds new HP. + + If Charizard (150 HP) has 60 damage and devolves to Charmander (50 HP), + the damage exceeds HP and should flag as knockout. + """ + # Set high damage that exceeds Charmander's HP + p1 = evolved_game_state.players["player1"] + charizard = p1.get_active_pokemon() + charizard.damage = 60 # Exceeds Charmander's 50 HP + + ctx = EffectContext( + game=evolved_game_state, + source_player_id="player2", + source_card_id="p2-active", + params={"stages": 2, "destination": "hand"}, # Devolve to Basic + target_card_id="p1-charizard", + rng=seeded_rng, + ) + + result = resolve_effect("devolve", ctx) + + assert result.success + assert "knocked out" in result.message.lower() + assert result.details.get("knockout") is True + + def test_cannot_devolve_basic_pokemon( + self, game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that attempting to devolve a Basic Pokemon fails. + + A Basic Pokemon has no cards_underneath and cannot be devolved. + """ + ctx = EffectContext( + game=game_state, + source_player_id="player2", + source_card_id="p2-charmander", + params={"stages": 1}, + target_card_id="p1-charmander", # This is a Basic + rng=seeded_rng, + ) + + result = resolve_effect("devolve", ctx) + + assert not result.success + assert "not an evolved Pokemon" in result.message + + def test_devolve_energy_remains_attached( + self, evolved_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that energy remains attached after devolve. + + This is the designed behavior - energy stays with the Pokemon + through devolution. + """ + # Verify initial energy count + p1 = evolved_game_state.players["player1"] + charizard = p1.get_active_pokemon() + initial_energy_ids = [e.instance_id for e in charizard.attached_energy] + assert len(initial_energy_ids) == 3 + + ctx = EffectContext( + game=evolved_game_state, + source_player_id="player2", + source_card_id="p2-active", + params={"stages": 2}, # Devolve all the way to Basic + target_card_id="p1-charizard", + rng=seeded_rng, + ) + + result = resolve_effect("devolve", ctx) + assert result.success + + # Get the devolved Pokemon (now Charmander) + charmander = p1.get_active_pokemon() + + # All energy should still be attached + final_energy_ids = [e.instance_id for e in charmander.attached_energy] + assert len(final_energy_ids) == 3 + assert set(final_energy_ids) == set(initial_energy_ids) + + +# ============================================================================= +# Knockout with Attachments Tests +# ============================================================================= + + +class TestKnockoutWithAttachments: + """Tests for knockout processing with attached cards.""" + + @pytest.fixture + def knockout_game_state( + self, + basic_pokemon_def: CardDefinition, + stage1_pokemon_def: CardDefinition, + fire_energy_def: CardDefinition, + tool_def: CardDefinition, + ) -> GameState: + """Create a game state for knockout testing with attached cards.""" + game = GameState( + game_id="test-knockout", + rules=RulesConfig(), + card_registry={ + basic_pokemon_def.id: basic_pokemon_def, + stage1_pokemon_def.id: stage1_pokemon_def, + fire_energy_def.id: fire_energy_def, + tool_def.id: tool_def, + }, + players={ + "player1": PlayerState(player_id="player1"), + "player2": PlayerState(player_id="player2"), + }, + turn_order=["player1", "player2"], + current_player_id="player2", + turn_number=5, + phase=TurnPhase.END, + ) + + p1 = game.players["player1"] + + # Build Charmeleon with Charmander underneath + charmander = CardInstance( + instance_id="ko-charmander", + definition_id=basic_pokemon_def.id, + ) + charmeleon = CardInstance( + instance_id="ko-charmeleon", + definition_id=stage1_pokemon_def.id, + cards_underneath=[charmander], + ) + + # Attach energy and tool + energy1 = CardInstance(instance_id="ko-energy-1", definition_id=fire_energy_def.id) + energy2 = CardInstance(instance_id="ko-energy-2", definition_id=fire_energy_def.id) + tool = CardInstance(instance_id="ko-tool", definition_id=tool_def.id) + + charmeleon.attach_energy(energy1) + charmeleon.attach_energy(energy2) + charmeleon.attach_tool(tool) + + # Set damage to cause knockout (HP is 80) + charmeleon.damage = 80 + + p1.active.add(charmeleon) + + # Add bench Pokemon for after KO + bench_pokemon = CardInstance( + instance_id="p1-bench", + definition_id=basic_pokemon_def.id, + ) + p1.bench.add(bench_pokemon) + + # Deck + for i in range(5): + card = CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id) + p1.deck.add(card) + + # Player 2 + p2 = game.players["player2"] + p2_active = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id) + p2.active.add(p2_active) + + for i in range(5): + card = CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id) + p2.deck.add(card) + + return game + + def test_attached_energy_goes_to_discard_on_knockout( + self, knockout_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that attached energy cards go to the owner's discard pile on knockout. + + When a Pokemon is knocked out, all attached energy CardInstance objects + should be moved to the player's discard pile. + """ + turn_manager = TurnManager() + p1 = knockout_game_state.players["player1"] + + # Verify energy is attached + charmeleon = p1.get_active_pokemon() + assert len(charmeleon.attached_energy) == 2 + + # Initial discard should be empty + assert len(p1.discard.cards) == 0 + + # Process knockout (knocked_out_id, opponent_id who scores) + turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2") + + # Energy should now be in discard + assert "ko-energy-1" in p1.discard + assert "ko-energy-2" in p1.discard + + def test_attached_tools_go_to_discard_on_knockout( + self, knockout_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that attached tool cards go to the owner's discard pile on knockout. + """ + turn_manager = TurnManager() + p1 = knockout_game_state.players["player1"] + + charmeleon = p1.get_active_pokemon() + assert len(charmeleon.attached_tools) == 1 + + turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2") + + assert "ko-tool" in p1.discard + + def test_evolution_stack_goes_to_discard_on_knockout( + self, knockout_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that the entire evolution stack goes to discard on knockout. + + When Charmeleon is knocked out, both Charmeleon and the Charmander + underneath should go to the discard pile. + """ + turn_manager = TurnManager() + p1 = knockout_game_state.players["player1"] + + charmeleon = p1.get_active_pokemon() + assert len(charmeleon.cards_underneath) == 1 + + turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2") + + # Both the evolved Pokemon and the Basic underneath should be in discard + assert "ko-charmeleon" in p1.discard + assert "ko-charmander" in p1.discard + + def test_all_attachments_discard_together_on_knockout( + self, knockout_game_state: GameState, seeded_rng: SeededRandom + ) -> None: + """ + Test that all attachments (energy, tools, stack) go to discard on knockout. + + Comprehensive test that energy, tools, and evolution stack all end up + in the discard pile when a Pokemon is knocked out. + """ + turn_manager = TurnManager() + p1 = knockout_game_state.players["player1"] + + initial_discard_count = len(p1.discard.cards) + + turn_manager.process_knockout(knockout_game_state, "ko-charmeleon", "player2") + + # Should have added: 2 energy + 1 tool + 1 Pokemon underneath + 1 KO'd Pokemon = 5 cards + expected_cards_in_discard = initial_discard_count + 5 + assert len(p1.discard.cards) == expected_cards_in_discard + + # Verify all specific cards + assert "ko-energy-1" in p1.discard + assert "ko-energy-2" in p1.discard + assert "ko-tool" in p1.discard + assert "ko-charmander" in p1.discard + assert "ko-charmeleon" in p1.discard + + +# ============================================================================= +# find_card_instance Tests for Attached Cards +# ============================================================================= + + +class TestFindCardInstanceWithAttachments: + """Tests for finding cards in attachments via find_card_instance.""" + + def test_find_attached_energy_by_id( + self, game_state: GameState, fire_energy_def: CardDefinition + ) -> None: + """ + Test that find_card_instance can locate energy attached to a Pokemon. + + Energy cards stored in attached_energy should be findable by their + instance_id. + """ + p1 = game_state.players["player1"] + charmander = p1.get_active_pokemon() + + # Attach energy + energy = CardInstance(instance_id="findable-energy", definition_id=fire_energy_def.id) + charmander.attach_energy(energy) + + # Find the energy + card, zone_name = game_state.find_card_instance("findable-energy") + + assert card is not None + assert card.instance_id == "findable-energy" + assert zone_name == "attached_energy" + + def test_find_attached_tool_by_id( + self, game_state: GameState, tool_def: CardDefinition + ) -> None: + """ + Test that find_card_instance can locate tools attached to a Pokemon. + """ + p1 = game_state.players["player1"] + charmander = p1.get_active_pokemon() + + # Attach tool + tool = CardInstance(instance_id="findable-tool", definition_id=tool_def.id) + charmander.attach_tool(tool) + + # Find the tool + card, zone_name = game_state.find_card_instance("findable-tool") + + assert card is not None + assert card.instance_id == "findable-tool" + assert zone_name == "attached_tools" + + def test_find_card_in_evolution_stack( + self, + game_state: GameState, + basic_pokemon_def: CardDefinition, + stage1_pokemon_def: CardDefinition, + ) -> None: + """ + Test that find_card_instance can locate cards in the evolution stack. + + When searching for the Basic that's underneath a Stage 1, it should + be found in cards_underneath. + """ + p1 = game_state.players["player1"] + + # Build evolved Pokemon manually + charmander = CardInstance( + instance_id="underneath-charmander", + definition_id=basic_pokemon_def.id, + ) + charmeleon = CardInstance( + instance_id="evolved-charmeleon", + definition_id=stage1_pokemon_def.id, + cards_underneath=[charmander], + ) + + # Replace active with the evolved Pokemon + p1.active.clear() + p1.active.add(charmeleon) + + # Find the Pokemon underneath + card, zone_name = game_state.find_card_instance("underneath-charmander") + + assert card is not None + assert card.instance_id == "underneath-charmander" + assert zone_name == "cards_underneath" + + def test_find_attached_card_on_bench_pokemon( + self, + game_state: GameState, + fire_energy_def: CardDefinition, + basic_pokemon_def: CardDefinition, + ) -> None: + """ + Test finding attached cards on benched Pokemon. + + Energy attached to bench Pokemon should also be findable. + """ + p1 = game_state.players["player1"] + + # Add a bench Pokemon with energy + bench_pokemon = CardInstance( + instance_id="bench-pokemon", + definition_id=basic_pokemon_def.id, + ) + energy = CardInstance(instance_id="bench-energy", definition_id=fire_energy_def.id) + bench_pokemon.attach_energy(energy) + p1.bench.add(bench_pokemon) + + # Find the bench Pokemon's energy + card, zone_name = game_state.find_card_instance("bench-energy") + + assert card is not None + assert card.instance_id == "bench-energy" + assert zone_name == "attached_energy" + + def test_find_returns_none_for_nonexistent_card(self, game_state: GameState) -> None: + """ + Test that find_card_instance returns None for cards that don't exist. + """ + card, zone_name = game_state.find_card_instance("nonexistent-card-id") + assert card is None + assert zone_name is None + + +# ============================================================================= +# CardInstance Method Tests +# ============================================================================= + + +class TestCardInstanceAttachmentMethods: + """Tests for CardInstance attachment methods.""" + + def test_attach_energy_adds_card_instance(self, fire_energy_def: CardDefinition) -> None: + """ + Test that attach_energy properly adds a CardInstance to attached_energy. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + energy = CardInstance(instance_id="energy-1", definition_id=fire_energy_def.id) + + pokemon.attach_energy(energy) + + assert len(pokemon.attached_energy) == 1 + assert pokemon.attached_energy[0] is energy + assert pokemon.attached_energy[0].instance_id == "energy-1" + + def test_detach_energy_removes_and_returns_card_instance( + self, fire_energy_def: CardDefinition + ) -> None: + """ + Test that detach_energy removes and returns the CardInstance. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + energy1 = CardInstance(instance_id="energy-1", definition_id=fire_energy_def.id) + energy2 = CardInstance(instance_id="energy-2", definition_id=fire_energy_def.id) + + pokemon.attach_energy(energy1) + pokemon.attach_energy(energy2) + + assert len(pokemon.attached_energy) == 2 + + # Detach energy-1 + detached = pokemon.detach_energy("energy-1") + + assert detached is not None + assert detached.instance_id == "energy-1" + assert len(pokemon.attached_energy) == 1 + assert pokemon.attached_energy[0].instance_id == "energy-2" + + def test_detach_energy_returns_none_for_not_attached(self) -> None: + """ + Test that detach_energy returns None when the energy isn't attached. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + + result = pokemon.detach_energy("nonexistent-energy") + + assert result is None + + def test_attach_tool_adds_card_instance(self, tool_def: CardDefinition) -> None: + """ + Test that attach_tool properly adds a CardInstance to attached_tools. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + tool = CardInstance(instance_id="tool-1", definition_id=tool_def.id) + + pokemon.attach_tool(tool) + + assert len(pokemon.attached_tools) == 1 + assert pokemon.attached_tools[0] is tool + + def test_detach_tool_removes_and_returns_card_instance(self, tool_def: CardDefinition) -> None: + """ + Test that detach_tool removes and returns the CardInstance. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + tool = CardInstance(instance_id="tool-1", definition_id=tool_def.id) + + pokemon.attach_tool(tool) + + detached = pokemon.detach_tool("tool-1") + + assert detached is not None + assert detached.instance_id == "tool-1" + assert len(pokemon.attached_tools) == 0 + + def test_get_all_attached_cards_returns_all( + self, fire_energy_def: CardDefinition, tool_def: CardDefinition + ) -> None: + """ + Test that get_all_attached_cards returns energy and tools. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + energy = CardInstance(instance_id="energy", definition_id=fire_energy_def.id) + tool = CardInstance(instance_id="tool", definition_id=tool_def.id) + + pokemon.attach_energy(energy) + pokemon.attach_tool(tool) + + all_attached = pokemon.get_all_attached_cards() + + assert len(all_attached) == 2 + ids = [c.instance_id for c in all_attached] + assert "energy" in ids + assert "tool" in ids + + def test_multiple_energy_attachment(self, fire_energy_def: CardDefinition) -> None: + """ + Test that multiple energy cards can be attached. + """ + pokemon = CardInstance(instance_id="pokemon", definition_id="charmander-001") + + for i in range(5): + energy = CardInstance(instance_id=f"energy-{i}", definition_id=fire_energy_def.id) + pokemon.attach_energy(energy) + + assert len(pokemon.attached_energy) == 5 + + # Verify order is preserved + for i in range(5): + assert pokemon.attached_energy[i].instance_id == f"energy-{i}" diff --git a/backend/tests/core/test_models/test_card.py b/backend/tests/core/test_models/test_card.py index d59525a..7814561 100644 --- a/backend/tests/core/test_models/test_card.py +++ b/backend/tests/core/test_models/test_card.py @@ -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 diff --git a/backend/tests/core/test_rules_validator.py b/backend/tests/core/test_rules_validator.py index 21109d5..235ca76 100644 --- a/backend/tests/core/test_rules_validator.py +++ b/backend/tests/core/test_rules_validator.py @@ -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)