Architectural refactor to eliminate circular imports and establish clean module boundaries: - Move enums from app/core/models/enums.py to app/core/enums.py (foundational module with zero dependencies) - Update all imports across 30 files to use new enum location - Set up clean export structure: - app.core.enums: canonical source for all enums - app.core: convenience exports for full public API - app.core.models: exports models only (not enums) - Add module exports to app/core/__init__.py and app/core/effects/__init__.py - Remove circular import workarounds from game_state.py This enables app.core.models to export GameState without circular import issues, since enums no longer depend on the models package. All 826 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.enums import (
|
|
CardType,
|
|
EnergyType,
|
|
PokemonStage,
|
|
PokemonVariant,
|
|
StatusCondition,
|
|
TrainerType,
|
|
TurnPhase,
|
|
)
|
|
from app.core.models.actions import EvolvePokemonAction
|
|
from app.core.models.card import Attack, CardDefinition, CardInstance
|
|
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}"
|