- Add model_validator to enforce card-type-specific required fields - Pokemon: require hp (positive), stage, pokemon_type - Pokemon Stage 1/2 and VMAX/VSTAR: require evolves_from - Trainer: require trainer_type - Energy: require energy_type (auto-fills energy_provides) - Update all test fixtures to include required fields - Mark Issue #2 as FIXED in SYSTEM_REVIEW.md 765 tests passing
1171 lines
40 KiB
Python
1171 lines
40 KiB
Python
"""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,
|
|
TrainerType,
|
|
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=TrainerType.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}"
|