mantimon-tcg/backend/tests/core/test_engine.py
Cal Corum 7fae1c61e8 Add CardDefinition validation for required fields (Issue #2)
- 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
2026-01-26 10:28:37 -06:00

1920 lines
62 KiB
Python

"""Integration tests for the GameEngine orchestrator.
This module tests the full game flow from creation through actions to win
conditions. These are integration tests that verify all components work
together correctly.
Test categories:
- Game creation and initialization
- Action validation through engine
- Action execution and state changes
- Turn management integration
- Win condition detection
- Full game playthrough scenarios
"""
import pytest
from app.core.config import RulesConfig
from app.core.engine import GameEngine
from app.core.models.actions import (
AttachEnergyAction,
AttackAction,
EvolvePokemonAction,
PassAction,
PlayPokemonAction,
PlayTrainerAction,
ResignAction,
RetreatAction,
SelectActiveAction,
UseAbilityAction,
)
from app.core.models.card import Ability, Attack, CardDefinition, CardInstance
from app.core.models.enums import (
CardType,
EnergyType,
GameEndReason,
PokemonStage,
PokemonVariant,
TrainerType,
TurnPhase,
)
from app.core.models.game_state import ForcedAction, GameState
from app.core.rng import SeededRandom
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def seeded_rng() -> SeededRandom:
"""Create a seeded RNG for deterministic tests."""
return SeededRandom(seed=42)
@pytest.fixture
def basic_pokemon_def() -> CardDefinition:
"""Create a basic Pokemon with an attack."""
return CardDefinition(
id="pikachu-001",
name="Pikachu",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.NORMAL,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
attacks=[
Attack(
name="Thunder Shock",
damage=20,
cost=[EnergyType.LIGHTNING],
),
],
retreat_cost=1,
)
@pytest.fixture
def strong_pokemon_def() -> CardDefinition:
"""Create a strong Pokemon for knockout tests."""
return CardDefinition(
id="raichu-001",
name="Raichu",
card_type=CardType.POKEMON,
stage=PokemonStage.STAGE_1,
variant=PokemonVariant.NORMAL,
hp=100,
pokemon_type=EnergyType.LIGHTNING,
evolves_from="Pikachu",
attacks=[
Attack(
name="Thunder",
damage=80,
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING],
),
],
retreat_cost=2,
)
@pytest.fixture
def energy_def() -> CardDefinition:
"""Create a basic energy card."""
return CardDefinition(
id="lightning-energy",
name="Lightning Energy",
card_type=CardType.ENERGY,
energy_type=EnergyType.LIGHTNING,
)
@pytest.fixture
def card_registry(
basic_pokemon_def: CardDefinition,
strong_pokemon_def: CardDefinition,
energy_def: CardDefinition,
) -> dict[str, CardDefinition]:
"""Create a card registry with test cards."""
return {
basic_pokemon_def.id: basic_pokemon_def,
strong_pokemon_def.id: strong_pokemon_def,
energy_def.id: energy_def,
}
@pytest.fixture
def player1_deck(
basic_pokemon_def: CardDefinition, energy_def: CardDefinition
) -> list[CardInstance]:
"""Create a deck for player 1."""
cards = []
# Add 10 basic Pokemon
for i in range(10):
cards.append(
CardInstance(instance_id=f"p1-pokemon-{i}", definition_id=basic_pokemon_def.id)
)
# Add 30 energy
for i in range(30):
cards.append(CardInstance(instance_id=f"p1-energy-{i}", definition_id=energy_def.id))
return cards
@pytest.fixture
def player2_deck(
basic_pokemon_def: CardDefinition, energy_def: CardDefinition
) -> list[CardInstance]:
"""Create a deck for player 2."""
cards = []
# Add 10 basic Pokemon
for i in range(10):
cards.append(
CardInstance(instance_id=f"p2-pokemon-{i}", definition_id=basic_pokemon_def.id)
)
# Add 30 energy
for i in range(30):
cards.append(CardInstance(instance_id=f"p2-energy-{i}", definition_id=energy_def.id))
return cards
@pytest.fixture
def engine(seeded_rng: SeededRandom) -> GameEngine:
"""Create a GameEngine with default rules and seeded RNG."""
return GameEngine(rules=RulesConfig(), rng=seeded_rng)
# =============================================================================
# Game Creation Tests
# =============================================================================
class TestGameCreation:
"""Tests for game creation and initialization."""
def test_create_game_success(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
):
"""
Test successful game creation with valid inputs.
Verifies game is created with correct initial state.
"""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
assert result.success
assert result.game is not None
assert result.game.game_id is not None
assert len(result.game.players) == 2
assert result.game.turn_number == 1
assert result.game.phase == TurnPhase.SETUP
def test_create_game_deals_starting_hands(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation deals starting hands.
Each player should have cards in hand after creation.
"""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
assert result.success
game = result.game
assert len(game.players["player1"].hand) > 0
assert len(game.players["player2"].hand) > 0
def test_create_game_shuffles_decks(
self,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation shuffles decks differently with different seeds.
Verifies decks are actually shuffled and RNG affects order.
"""
engine1 = GameEngine(rng=SeededRandom(seed=1))
engine2 = GameEngine(rng=SeededRandom(seed=2))
result1 = engine1.create_game(
player_ids=["player1", "player2"],
decks={"player1": list(player1_deck), "player2": list(player2_deck)},
card_registry=card_registry,
)
result2 = engine2.create_game(
player_ids=["player1", "player2"],
decks={"player1": list(player1_deck), "player2": list(player2_deck)},
card_registry=card_registry,
)
# Different seeds should result in different deck orders
deck1 = [c.instance_id for c in result1.game.players["player1"].deck.cards]
deck2 = [c.instance_id for c in result2.game.players["player1"].deck.cards]
assert deck1 != deck2
def test_create_game_wrong_player_count(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation fails with wrong player count.
"""
result = engine.create_game(
player_ids=["player1"], # Only 1 player
decks={"player1": player1_deck},
card_registry=card_registry,
)
assert not result.success
assert "2 players" in result.message
def test_create_game_missing_deck(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation fails when a player has no deck.
"""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck}, # Missing player2's deck
card_registry=card_registry,
)
assert not result.success
assert "player2" in result.message
def test_create_game_deck_too_small(
self,
engine: GameEngine,
basic_pokemon_def: CardDefinition,
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation fails with undersized deck.
"""
small_deck = [
CardInstance(instance_id=f"card-{i}", definition_id=basic_pokemon_def.id)
for i in range(10) # Too small
]
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": small_deck, "player2": small_deck},
card_registry=card_registry,
)
assert not result.success
assert "too small" in result.message
def test_create_game_no_basic_pokemon(
self,
engine: GameEngine,
energy_def: CardDefinition,
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation fails when deck has no Basic Pokemon.
"""
energy_only_deck = [
CardInstance(instance_id=f"energy-{i}", definition_id=energy_def.id) for i in range(40)
]
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": energy_only_deck, "player2": energy_only_deck},
card_registry=card_registry,
)
assert not result.success
assert "Basic Pokemon" in result.message
# =============================================================================
# Action Validation Tests
# =============================================================================
class TestActionValidation:
"""Tests for action validation through the engine."""
@pytest.fixture
def active_game(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
) -> GameState:
"""Create a game and set up for play."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Set up active Pokemon for both players
p1 = game.players["player1"]
p2 = game.players["player2"]
# Find a basic Pokemon in each hand and play to active
for card in list(p1.hand.cards):
card_def = card_registry.get(card.definition_id)
if card_def and card_def.is_basic_pokemon():
p1.hand.remove(card.instance_id)
p1.active.add(card)
break
for card in list(p2.hand.cards):
card_def = card_registry.get(card.definition_id)
if card_def and card_def.is_basic_pokemon():
p2.hand.remove(card.instance_id)
p2.active.add(card)
break
# Start the game
game.phase = TurnPhase.MAIN
return game
def test_validate_action_wrong_turn(
self,
engine: GameEngine,
active_game: GameState,
):
"""
Test that actions are rejected when it's not your turn.
"""
# It's player1's turn, player2 tries to act
action = PassAction()
result = engine.validate_action(active_game, "player2", action)
assert not result.valid
assert "Not your turn" in result.reason
def test_validate_resign_always_allowed(
self,
engine: GameEngine,
active_game: GameState,
):
"""
Test that resignation is allowed even on opponent's turn.
"""
action = ResignAction()
result = engine.validate_action(active_game, "player2", action)
assert result.valid
# =============================================================================
# Action Execution Tests
# =============================================================================
class TestActionExecution:
"""Tests for action execution through the engine."""
@pytest.fixture
def ready_game(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
) -> GameState:
"""Create a game ready for action execution testing."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Set up active and bench Pokemon
p1 = game.players["player1"]
p2 = game.players["player2"]
# Player 1: active + 1 bench + energy in hand
active1 = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
bench1 = CardInstance(instance_id="p1-bench-1", definition_id=basic_pokemon_def.id)
energy1 = CardInstance(instance_id="p1-energy-hand", definition_id=energy_def.id)
p1.active.add(active1)
p1.bench.add(bench1)
p1.hand.add(energy1)
# Player 2: active only
active2 = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(active2)
game.phase = TurnPhase.MAIN
game.turn_number = 2 # Not first turn
return game
@pytest.mark.asyncio
async def test_execute_attach_energy(
self,
engine: GameEngine,
ready_game: GameState,
):
"""
Test executing an attach energy action.
"""
action = AttachEnergyAction(
energy_card_id="p1-energy-hand",
target_pokemon_id="p1-active",
from_energy_zone=False,
)
result = await engine.execute_action(ready_game, "player1", action)
assert result.success
assert "Energy attached" in result.message
# Verify energy is attached (now stored as CardInstance objects)
active = ready_game.players["player1"].get_active_pokemon()
assert any(e.instance_id == "p1-energy-hand" for e in active.attached_energy)
@pytest.mark.asyncio
async def test_execute_attack(
self,
engine: GameEngine,
ready_game: GameState,
energy_def: CardDefinition,
):
"""
Test executing an attack action.
"""
# 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.get_active_pokemon().attach_energy(energy)
# Need to be in ATTACK phase for attack action
ready_game.phase = TurnPhase.ATTACK
action = AttackAction(attack_index=0)
result = await engine.execute_action(ready_game, "player1", action)
assert result.success
assert "Thunder Shock" in result.message
assert "20 damage" in result.message
# Verify damage dealt
defender = ready_game.players["player2"].get_active_pokemon()
assert defender.damage == 20
# Phase should advance to END
assert ready_game.phase == TurnPhase.END
@pytest.mark.asyncio
async def test_execute_pass(
self,
engine: GameEngine,
ready_game: GameState,
):
"""
Test executing a pass action.
"""
action = PassAction()
result = await engine.execute_action(ready_game, "player1", action)
assert result.success
assert ready_game.phase == TurnPhase.END
@pytest.mark.asyncio
async def test_execute_resign(
self,
engine: GameEngine,
ready_game: GameState,
):
"""
Test executing a resignation.
"""
action = ResignAction()
result = await engine.execute_action(ready_game, "player1", action)
assert result.success
assert result.win_result is not None
assert result.win_result.winner_id == "player2"
assert result.win_result.end_reason == GameEndReason.RESIGNATION
@pytest.mark.asyncio
async def test_execute_retreat(
self,
engine: GameEngine,
ready_game: GameState,
):
"""
Test executing a retreat action.
"""
# Attach energy for retreat cost (now a CardInstance)
active = ready_game.players["player1"].get_active_pokemon()
retreat_energy = CardInstance(instance_id="retreat-energy", definition_id="fire_energy")
active.attach_energy(retreat_energy)
action = RetreatAction(
new_active_id="p1-bench-1",
energy_to_discard=["retreat-energy"],
)
result = await engine.execute_action(ready_game, "player1", action)
assert result.success
assert "Retreated" in result.message
# Verify Pokemon swapped
new_active = ready_game.players["player1"].get_active_pokemon()
assert new_active.instance_id == "p1-bench-1"
@pytest.mark.asyncio
async def test_execute_invalid_action_fails(
self,
engine: GameEngine,
ready_game: GameState,
):
"""
Test that invalid actions return failure.
"""
# Try to attach non-existent energy
action = AttachEnergyAction(
energy_card_id="nonexistent-energy",
target_pokemon_id="p1-active",
from_energy_zone=False,
)
result = await engine.execute_action(ready_game, "player1", action)
assert not result.success
# =============================================================================
# Turn Management Tests
# =============================================================================
class TestTurnManagement:
"""Tests for turn management through the engine."""
@pytest.fixture
def game_at_start(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
) -> GameState:
"""Create a game at the start of a turn."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Set up active Pokemon
p1 = game.players["player1"]
p2 = game.players["player2"]
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
game.phase = TurnPhase.SETUP
game.turn_number = 1
return game
def test_start_turn(
self,
engine: GameEngine,
game_at_start: GameState,
):
"""
Test starting a turn through the engine.
"""
result = engine.start_turn(game_at_start)
assert result.success
assert game_at_start.phase == TurnPhase.MAIN
def test_end_turn(
self,
engine: GameEngine,
game_at_start: GameState,
):
"""
Test ending a turn through the engine.
"""
game_at_start.phase = TurnPhase.END
original_player = game_at_start.current_player_id
result = engine.end_turn(game_at_start)
assert result.success
assert game_at_start.current_player_id != original_player
# =============================================================================
# Win Condition Tests
# =============================================================================
class TestWinConditions:
"""Tests for win condition detection through the engine."""
@pytest.fixture
def near_win_game(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
) -> GameState:
"""Create a game where player1 is close to winning."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Player 1 has 3 points (needs 4 to win)
game.players["player1"].score = 3
# Set up active Pokemon
p1 = game.players["player1"]
p2 = game.players["player2"]
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
# Player 2 active has 50 damage (60 HP, 20 more will KO)
p2_active = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2_active.damage = 50
p2.active.add(p2_active)
game.phase = TurnPhase.MAIN
game.turn_number = 5
return game
@pytest.mark.asyncio
async def test_knockout_triggers_win(
self,
engine: GameEngine,
near_win_game: GameState,
energy_def: CardDefinition,
):
"""
Test that a knockout that reaches win threshold ends the game.
"""
# Attack will deal 20 damage, which KOs the defender (50 + 20 = 70 > 60)
# 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)
# 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
action = AttackAction(attack_index=0)
result = await engine.execute_action(near_win_game, "player1", action)
assert result.success
assert result.win_result is not None
assert result.win_result.winner_id == "player1"
assert result.win_result.end_reason == GameEndReason.PRIZES_TAKEN
def test_timeout_ends_game(
self,
engine: GameEngine,
near_win_game: GameState,
):
"""
Test that timeout triggers win for opponent.
"""
result = engine.handle_timeout(near_win_game, "player1")
assert result.success
assert result.win_result is not None
assert result.win_result.winner_id == "player2"
assert result.win_result.end_reason == GameEndReason.TIMEOUT
# =============================================================================
# Visibility Tests
# =============================================================================
class TestVisibility:
"""Tests for visibility filtering through the engine."""
@pytest.fixture
def active_game(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
) -> GameState:
"""Create an active game for visibility tests."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
return result.game
def test_get_visible_state(
self,
engine: GameEngine,
active_game: GameState,
):
"""
Test getting a visible state through the engine.
"""
visible = engine.get_visible_state(active_game, "player1")
assert visible.viewer_id == "player1"
assert visible.game_id == active_game.game_id
assert len(visible.players) == 2
def test_get_spectator_state(
self,
engine: GameEngine,
active_game: GameState,
):
"""
Test getting a spectator state through the engine.
"""
visible = engine.get_spectator_state(active_game)
assert visible.viewer_id == "__spectator__"
# No hands should be visible
for player_state in visible.players.values():
assert len(player_state.hand.cards) == 0
# =============================================================================
# Integration Scenario Tests
# =============================================================================
class TestIntegrationScenarios:
"""Full game scenario tests."""
@pytest.mark.asyncio
async def test_full_turn_cycle(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
):
"""
Test a complete turn cycle: create game -> start turn -> actions -> end turn.
"""
# Create game
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
assert result.success
# Set up active Pokemon
p1 = game.players["player1"]
p2 = game.players["player2"]
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
# Start turn
start_result = engine.start_turn(game)
assert start_result.success
assert game.phase == TurnPhase.MAIN
# Execute pass action
pass_result = await engine.execute_action(game, game.current_player_id, PassAction())
assert pass_result.success
assert game.phase == TurnPhase.END
# End turn
end_result = engine.end_turn(game)
assert end_result.success
# Verify turn advanced
assert game.current_player_id == "player2"
# =============================================================================
# Game Creation - Energy Deck and Prize Card Tests
# =============================================================================
class TestGameCreationAdvanced:
"""Tests for advanced game creation features."""
@pytest.fixture
def energy_deck(self, energy_def: CardDefinition) -> list[CardInstance]:
"""Create an energy deck for Pokemon Pocket style energy."""
return [
CardInstance(instance_id=f"edeck-energy-{i}", definition_id=energy_def.id)
for i in range(20)
]
def test_create_game_with_energy_deck(
self,
seeded_rng: SeededRandom,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
energy_deck: list[CardInstance],
):
"""
Test game creation with separate energy decks (Pokemon Pocket style).
Verifies energy decks are shuffled and assigned to each player's
energy_deck zone for the flip-to-gain mechanic.
"""
engine = GameEngine(rules=RulesConfig(), rng=seeded_rng)
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
energy_decks={
"player1": list(energy_deck),
"player2": [
CardInstance(instance_id=f"p2-edeck-{i}", definition_id="lightning-energy")
for i in range(20)
],
},
)
assert result.success
game = result.game
# Energy decks should be populated
assert len(game.players["player1"].energy_deck) == 20
assert len(game.players["player2"].energy_deck) == 20
def test_create_game_with_prize_cards(
self,
seeded_rng: SeededRandom,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
):
"""
Test game creation with prize card mode enabled.
Verifies prize cards are dealt from the deck to the prizes zone.
"""
rules = RulesConfig()
rules.prizes.use_prize_cards = True
rules.prizes.count = 6
engine = GameEngine(rules=rules, rng=seeded_rng)
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
assert result.success
game = result.game
# Prize cards should be dealt
assert len(game.players["player1"].prizes) == 6
assert len(game.players["player2"].prizes) == 6
def test_create_game_deck_too_large(
self,
engine: GameEngine,
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
card_registry: dict[str, CardDefinition],
):
"""
Test that game creation fails with oversized deck.
Default max deck size is 60, so 70 cards should fail.
"""
large_deck = []
for i in range(10):
large_deck.append(
CardInstance(instance_id=f"pokemon-{i}", definition_id=basic_pokemon_def.id)
)
for i in range(60):
large_deck.append(CardInstance(instance_id=f"energy-{i}", definition_id=energy_def.id))
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": large_deck, "player2": large_deck},
card_registry=card_registry,
)
assert not result.success
assert "too large" in result.message
# =============================================================================
# Action Execution - Play Pokemon Tests
# =============================================================================
class TestPlayPokemonAction:
"""Tests for playing Pokemon from hand to field."""
@pytest.fixture
def game_for_pokemon(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
) -> GameState:
"""Create a game ready for playing Pokemon."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Player 1 has no active yet (clear setup)
p1 = game.players["player1"]
p1.active.cards.clear()
# Add a basic Pokemon to hand
basic = CardInstance(instance_id="hand-pikachu", definition_id=basic_pokemon_def.id)
p1.hand.add(basic)
# Player 2 has active
p2 = game.players["player2"]
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
game.phase = TurnPhase.MAIN
game.turn_number = 2
return game
@pytest.mark.asyncio
async def test_play_pokemon_to_active(
self,
engine: GameEngine,
game_for_pokemon: GameState,
):
"""
Test playing a Basic Pokemon to active when no active exists.
Pokemon should be placed in the active zone and marked with turn played.
"""
action = PlayPokemonAction(card_instance_id="hand-pikachu")
result = await engine.execute_action(game_for_pokemon, "player1", action)
assert result.success
assert "active" in result.message.lower()
# Verify Pokemon is now active
active = game_for_pokemon.players["player1"].get_active_pokemon()
assert active is not None
assert active.instance_id == "hand-pikachu"
assert active.turn_played == game_for_pokemon.turn_number
@pytest.mark.asyncio
async def test_play_pokemon_to_bench(
self,
engine: GameEngine,
game_for_pokemon: GameState,
basic_pokemon_def: CardDefinition,
):
"""
Test playing a Basic Pokemon to bench when active exists.
Pokemon should be placed on the bench when player already has an active.
"""
# Give player an active first
p1 = game_for_pokemon.players["player1"]
p1.active.add(
CardInstance(instance_id="existing-active", definition_id=basic_pokemon_def.id)
)
action = PlayPokemonAction(card_instance_id="hand-pikachu")
result = await engine.execute_action(game_for_pokemon, "player1", action)
assert result.success
assert "bench" in result.message.lower()
# Verify Pokemon is on bench
assert "hand-pikachu" in p1.bench
@pytest.mark.asyncio
async def test_play_pokemon_card_not_in_hand(
self,
engine: GameEngine,
game_for_pokemon: GameState,
):
"""
Test that playing a non-existent card fails.
"""
action = PlayPokemonAction(card_instance_id="nonexistent-card")
result = await engine.execute_action(game_for_pokemon, "player1", action)
assert not result.success
# =============================================================================
# Action Execution - Evolve Pokemon Tests
# =============================================================================
class TestEvolvePokemonAction:
"""Tests for evolving Pokemon."""
@pytest.fixture
def evolution_card_def(self, basic_pokemon_def: CardDefinition) -> CardDefinition:
"""Create a Stage 1 evolution card that evolves from basic_pokemon_def."""
return CardDefinition(
id="raichu-evo",
name="Raichu",
card_type=CardType.POKEMON,
stage=PokemonStage.STAGE_1,
variant=PokemonVariant.NORMAL,
hp=100,
pokemon_type=EnergyType.LIGHTNING,
evolves_from=basic_pokemon_def.name, # Must match the Pokemon's name
attacks=[
Attack(
name="Thunder",
damage=80,
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING],
),
],
retreat_cost=2,
)
@pytest.fixture
def game_for_evolution(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
evolution_card_def: CardDefinition,
) -> tuple[GameState, dict[str, CardDefinition]]:
"""Create a game ready for evolution testing."""
# Add evolution card to registry
registry = dict(card_registry)
registry[evolution_card_def.id] = evolution_card_def
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
# Player 1: active Pikachu (played last turn) with energy and damage
active = CardInstance(instance_id="active-pikachu", definition_id=basic_pokemon_def.id)
active.turn_played = 1 # Played last turn, can evolve
active.damage = 20
# 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
evo = CardInstance(instance_id="hand-raichu", definition_id=evolution_card_def.id)
p1.hand.add(evo)
# Player 2 active
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
game.phase = TurnPhase.MAIN
game.turn_number = 2 # Not first turn
return game, registry
@pytest.mark.asyncio
async def test_evolve_pokemon_success(
self,
engine: GameEngine,
game_for_evolution: tuple[GameState, dict],
):
"""
Test successfully evolving a Pokemon.
Evolution should transfer energy and damage from the base Pokemon.
"""
game, registry = game_for_evolution
action = EvolvePokemonAction(
evolution_card_id="hand-raichu",
target_pokemon_id="active-pikachu",
)
result = await engine.execute_action(game, "player1", action)
assert result.success
assert "evolved" in result.message.lower()
# Verify evolution happened
active = game.players["player1"].get_active_pokemon()
assert active.instance_id == "hand-raichu"
assert active.definition_id == "raichu-evo"
# Verify energy and damage transferred
assert any(e.instance_id == "attached-energy-1" for e in active.attached_energy)
assert active.damage == 20
# 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(
self,
engine: GameEngine,
game_for_evolution: tuple[GameState, dict],
):
"""
Test that evolving with a card not in hand fails.
"""
game, _ = game_for_evolution
action = EvolvePokemonAction(
evolution_card_id="nonexistent-raichu",
target_pokemon_id="active-pikachu",
)
result = await engine.execute_action(game, "player1", action)
assert not result.success
assert "not found in hand" in result.message.lower()
@pytest.mark.asyncio
async def test_evolve_pokemon_target_not_found(
self,
engine: GameEngine,
game_for_evolution: tuple[GameState, dict],
):
"""
Test that evolving a non-existent target fails.
"""
game, _ = game_for_evolution
action = EvolvePokemonAction(
evolution_card_id="hand-raichu",
target_pokemon_id="nonexistent-pikachu",
)
result = await engine.execute_action(game, "player1", action)
assert not result.success
# Evolution card should be returned to hand
assert "hand-raichu" in game.players["player1"].hand
# =============================================================================
# Action Execution - Play Trainer Tests
# =============================================================================
class TestPlayTrainerAction:
"""Tests for playing Trainer cards."""
@pytest.fixture
def item_card_def(self) -> CardDefinition:
"""Create an Item trainer card."""
return CardDefinition(
id="potion-001",
name="Potion",
card_type=CardType.TRAINER,
trainer_type=TrainerType.ITEM,
)
@pytest.fixture
def supporter_card_def(self) -> CardDefinition:
"""Create a Supporter trainer card."""
return CardDefinition(
id="professor-001",
name="Professor's Research",
card_type=CardType.TRAINER,
trainer_type=TrainerType.SUPPORTER,
)
@pytest.fixture
def stadium_card_def(self) -> CardDefinition:
"""Create a Stadium trainer card."""
return CardDefinition(
id="stadium-001",
name="Training Court",
card_type=CardType.TRAINER,
trainer_type=TrainerType.STADIUM,
)
@pytest.fixture
def game_for_trainer(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
item_card_def: CardDefinition,
supporter_card_def: CardDefinition,
stadium_card_def: CardDefinition,
) -> tuple[GameState, dict[str, CardDefinition]]:
"""Create a game ready for trainer card testing."""
registry = dict(card_registry)
registry[item_card_def.id] = item_card_def
registry[supporter_card_def.id] = supporter_card_def
registry[stadium_card_def.id] = stadium_card_def
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
# Active Pokemon
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
# Add trainers to hand
p1.hand.add(CardInstance(instance_id="hand-potion", definition_id=item_card_def.id))
p1.hand.add(CardInstance(instance_id="hand-professor", definition_id=supporter_card_def.id))
p1.hand.add(CardInstance(instance_id="hand-stadium", definition_id=stadium_card_def.id))
game.phase = TurnPhase.MAIN
game.turn_number = 2
return game, registry
@pytest.mark.asyncio
async def test_play_item_card(
self,
engine: GameEngine,
game_for_trainer: tuple[GameState, dict],
):
"""
Test playing an Item trainer card.
Item cards should be played and discarded, incrementing the item counter.
"""
game, _ = game_for_trainer
p1 = game.players["player1"]
action = PlayTrainerAction(card_instance_id="hand-potion")
result = await engine.execute_action(game, "player1", action)
assert result.success
assert "Trainer card played" in result.message
# Card should be discarded
assert "hand-potion" in p1.discard
assert "hand-potion" not in p1.hand
# Counter should be incremented
assert p1.items_played_this_turn == 1
@pytest.mark.asyncio
async def test_play_supporter_card(
self,
engine: GameEngine,
game_for_trainer: tuple[GameState, dict],
):
"""
Test playing a Supporter trainer card.
Supporter cards increment the supporter counter (limited per turn).
"""
game, _ = game_for_trainer
p1 = game.players["player1"]
action = PlayTrainerAction(card_instance_id="hand-professor")
result = await engine.execute_action(game, "player1", action)
assert result.success
assert p1.supporters_played_this_turn == 1
@pytest.mark.asyncio
async def test_play_stadium_card(
self,
engine: GameEngine,
game_for_trainer: tuple[GameState, dict],
):
"""
Test playing a Stadium trainer card.
Stadium cards go into play (not discarded) and replace existing stadiums.
"""
game, _ = game_for_trainer
action = PlayTrainerAction(card_instance_id="hand-stadium")
result = await engine.execute_action(game, "player1", action)
assert result.success
assert "Stadium played" in result.message
# Stadium should be in play
assert game.stadium_in_play is not None
assert game.stadium_in_play.instance_id == "hand-stadium"
@pytest.fixture
def different_stadium_def(self) -> CardDefinition:
"""Create a different stadium card for replacement tests."""
return CardDefinition(
id="stadium-002",
name="Power Plant",
card_type=CardType.TRAINER,
trainer_type=TrainerType.STADIUM,
)
@pytest.mark.asyncio
async def test_play_stadium_replaces_existing(
self,
engine: GameEngine,
game_for_trainer: tuple[GameState, dict],
stadium_card_def: CardDefinition,
different_stadium_def: CardDefinition,
):
"""
Test that playing a new Stadium replaces the existing one.
The old stadium should be discarded.
"""
game, _ = game_for_trainer
# Add the different stadium to the game's card registry
game.card_registry[different_stadium_def.id] = different_stadium_def
p1 = game.players["player1"]
# Put an existing stadium in play
old_stadium = CardInstance(instance_id="old-stadium", definition_id=stadium_card_def.id)
game.stadium_in_play = old_stadium
# Add a different stadium to hand (different from old one)
new_stadium = CardInstance(
instance_id="new-stadium", definition_id=different_stadium_def.id
)
p1.hand.add(new_stadium)
action = PlayTrainerAction(card_instance_id="new-stadium")
result = await engine.execute_action(game, "player1", action)
assert result.success
# New stadium should be in play
assert game.stadium_in_play.instance_id == "new-stadium"
# Old stadium should be discarded
assert "old-stadium" in p1.discard
@pytest.mark.asyncio
async def test_play_trainer_card_not_found(
self,
engine: GameEngine,
game_for_trainer: tuple[GameState, dict],
):
"""
Test that playing a non-existent trainer card fails.
"""
game, _ = game_for_trainer
action = PlayTrainerAction(card_instance_id="nonexistent-trainer")
result = await engine.execute_action(game, "player1", action)
assert not result.success
# =============================================================================
# Action Execution - Use Ability Tests
# =============================================================================
class TestUseAbilityAction:
"""Tests for using Pokemon abilities."""
@pytest.fixture
def pokemon_with_ability_def(self) -> CardDefinition:
"""Create a Pokemon with an ability."""
return CardDefinition(
id="pikachu-ability",
name="Pikachu",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.NORMAL,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
abilities=[
Ability(
name="Static",
description="Flip a coin. If heads, your opponent's Active Pokemon is Paralyzed.",
effect_id="paralyze_on_flip",
uses_per_turn=1,
),
],
attacks=[
Attack(name="Thunder Shock", damage=20, cost=[EnergyType.LIGHTNING]),
],
retreat_cost=1,
)
@pytest.fixture
def game_for_ability(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
pokemon_with_ability_def: CardDefinition,
) -> tuple[GameState, dict[str, CardDefinition]]:
"""Create a game ready for ability testing."""
registry = dict(card_registry)
registry[pokemon_with_ability_def.id] = pokemon_with_ability_def
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
# Player 1 has Pokemon with ability as active
active = CardInstance(
instance_id="ability-pikachu", definition_id=pokemon_with_ability_def.id
)
p1.active.add(active)
# Player 2 has active
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
game.phase = TurnPhase.MAIN
game.turn_number = 2
return game, registry
@pytest.mark.asyncio
async def test_use_ability_success(
self,
engine: GameEngine,
game_for_ability: tuple[GameState, dict],
):
"""
Test successfully using a Pokemon's ability.
Ability should be marked as used.
"""
game, _ = game_for_ability
action = UseAbilityAction(
pokemon_id="ability-pikachu",
ability_index=0,
)
result = await engine.execute_action(game, "player1", action)
assert result.success
assert "Static" in result.message
# Ability should be recorded as used
active = game.players["player1"].get_active_pokemon()
assert active.ability_uses_this_turn >= 1
@pytest.mark.asyncio
async def test_use_ability_pokemon_not_found(
self,
engine: GameEngine,
game_for_ability: tuple[GameState, dict],
):
"""
Test that using ability on non-existent Pokemon fails.
"""
game, _ = game_for_ability
action = UseAbilityAction(
pokemon_id="nonexistent-pokemon",
ability_index=0,
)
result = await engine.execute_action(game, "player1", action)
assert not result.success
assert "not found" in result.message.lower()
@pytest.mark.asyncio
async def test_use_ability_invalid_index(
self,
engine: GameEngine,
game_for_ability: tuple[GameState, dict],
):
"""
Test that using an invalid ability index fails.
"""
game, _ = game_for_ability
action = UseAbilityAction(
pokemon_id="ability-pikachu",
ability_index=5, # Invalid index
)
result = await engine.execute_action(game, "player1", action)
assert not result.success
assert "Invalid ability index" in result.message
# =============================================================================
# Action Execution - Select Active Tests (Forced Action)
# =============================================================================
class TestSelectActiveAction:
"""Tests for selecting a new active Pokemon (forced action after KO)."""
@pytest.fixture
def game_with_forced_action(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
) -> GameState:
"""Create a game with a forced action to select a new active."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
# Player 1 has no active (was KO'd) but has bench
p1.bench.add(CardInstance(instance_id="p1-bench-1", definition_id=basic_pokemon_def.id))
p1.bench.add(CardInstance(instance_id="p1-bench-2", definition_id=basic_pokemon_def.id))
# Player 2 has active
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
# Set forced action
game.forced_action = ForcedAction(
player_id="player1",
action_type="select_active",
reason="Active Pokemon was knocked out",
)
game.phase = TurnPhase.MAIN
game.turn_number = 2
return game
@pytest.mark.asyncio
async def test_select_active_success(
self,
engine: GameEngine,
game_with_forced_action: GameState,
):
"""
Test successfully selecting a new active Pokemon.
The selected Pokemon should move from bench to active and forced action cleared.
"""
action = SelectActiveAction(pokemon_id="p1-bench-1")
result = await engine.execute_action(game_with_forced_action, "player1", action)
assert result.success
assert "New active" in result.message
# Pokemon should now be active
p1 = game_with_forced_action.players["player1"]
active = p1.get_active_pokemon()
assert active is not None
assert active.instance_id == "p1-bench-1"
# Should not be on bench anymore
assert "p1-bench-1" not in p1.bench
# Forced action should be cleared
assert game_with_forced_action.forced_action is None
@pytest.mark.asyncio
async def test_select_active_not_on_bench(
self,
engine: GameEngine,
game_with_forced_action: GameState,
):
"""
Test that selecting a Pokemon not on bench fails.
"""
action = SelectActiveAction(pokemon_id="nonexistent-pokemon")
result = await engine.execute_action(game_with_forced_action, "player1", action)
assert not result.success
assert "not found on bench" in result.message.lower()
# =============================================================================
# Deck-Out and Win Condition Edge Cases
# =============================================================================
class TestDeckOutAndEdgeCases:
"""Tests for deck-out and edge case scenarios."""
@pytest.fixture
def game_about_to_deck_out(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
) -> GameState:
"""Create a game where player1 has an empty deck (will deck out on draw)."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
# Player 1 has empty deck
p1.deck.cards.clear()
# Both have actives
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
game.phase = TurnPhase.SETUP
game.turn_number = 5
return game
def test_deck_out_on_turn_start(
self,
engine: GameEngine,
game_about_to_deck_out: GameState,
):
"""
Test that drawing with an empty deck triggers loss.
When a player cannot draw at turn start, they lose the game.
"""
result = engine.start_turn(game_about_to_deck_out)
# Turn start should fail due to deck out
assert not result.success
assert result.win_result is not None
assert result.win_result.winner_id == "player2"
assert result.win_result.end_reason == GameEndReason.DECK_EMPTY
@pytest.fixture
def game_for_attack_edge_cases(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
) -> GameState:
"""Create a game for testing attack edge cases."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
game.phase = TurnPhase.ATTACK
game.turn_number = 2
return game
@pytest.mark.asyncio
async def test_attack_without_energy(
self,
engine: GameEngine,
game_for_attack_edge_cases: GameState,
):
"""
Test that attacking without required energy fails validation.
"""
action = AttackAction(attack_index=0)
result = await engine.execute_action(game_for_attack_edge_cases, "player1", action)
# Should fail validation due to insufficient energy
assert not result.success
@pytest.mark.asyncio
async def test_attack_invalid_index(
self,
engine: GameEngine,
game_for_attack_edge_cases: GameState,
energy_def: CardDefinition,
):
"""
Test that using an invalid attack index fails.
"""
# Attach energy so energy check passes
p1 = game_for_attack_edge_cases.players["player1"]
energy = CardInstance(instance_id="test-energy", definition_id=energy_def.id)
p1.discard.add(energy)
p1.get_active_pokemon().attach_energy(energy.instance_id)
action = AttackAction(attack_index=99) # Invalid index
result = await engine.execute_action(game_for_attack_edge_cases, "player1", action)
assert not result.success
@pytest.mark.asyncio
async def test_retreat_without_bench(
self,
engine: GameEngine,
game_for_attack_edge_cases: GameState,
):
"""
Test that retreating without a bench Pokemon fails.
"""
game_for_attack_edge_cases.phase = TurnPhase.MAIN
action = RetreatAction(
new_active_id="nonexistent",
energy_to_discard=[],
)
result = await engine.execute_action(game_for_attack_edge_cases, "player1", action)
assert not result.success
# =============================================================================
# Attach Energy - Energy Zone Tests
# =============================================================================
class TestAttachEnergyFromEnergyZone:
"""Tests for attaching energy from energy zone (Pokemon Pocket style)."""
@pytest.fixture
def game_with_energy_zone(
self,
engine: GameEngine,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
) -> GameState:
"""Create a game with energy in the energy zone."""
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
p1 = game.players["player1"]
p2 = game.players["player2"]
# Active Pokemon
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
# Energy in energy zone (flipped from energy deck)
energy = CardInstance(instance_id="zone-energy", definition_id=energy_def.id)
p1.energy_zone.add(energy)
game.phase = TurnPhase.MAIN
game.turn_number = 2
return game
@pytest.mark.asyncio
async def test_attach_energy_from_zone(
self,
engine: GameEngine,
game_with_energy_zone: GameState,
):
"""
Test attaching energy from the energy zone.
Energy zone is used in Pokemon Pocket style where energy is flipped
from a separate deck into a zone, then attached from there.
"""
action = AttachEnergyAction(
energy_card_id="zone-energy",
target_pokemon_id="p1-active",
from_energy_zone=True,
)
result = await engine.execute_action(game_with_energy_zone, "player1", action)
assert result.success
# Energy should be attached to active (now stored as CardInstance)
p1 = game_with_energy_zone.players["player1"]
active = p1.get_active_pokemon()
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
@pytest.mark.asyncio
async def test_attach_energy_to_bench(
self,
engine: GameEngine,
game_with_energy_zone: GameState,
basic_pokemon_def: CardDefinition,
):
"""
Test attaching energy to a bench Pokemon.
"""
# Add a bench Pokemon
p1 = game_with_energy_zone.players["player1"]
p1.bench.add(CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id))
action = AttachEnergyAction(
energy_card_id="zone-energy",
target_pokemon_id="p1-bench",
from_energy_zone=True,
)
result = await engine.execute_action(game_with_energy_zone, "player1", action)
assert result.success
# Energy should be attached to bench Pokemon (now stored as CardInstance)
bench_pokemon = p1.bench.get("p1-bench")
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(
self,
engine: GameEngine,
game_with_energy_zone: GameState,
):
"""
Test that attaching energy to non-existent Pokemon fails and returns energy.
"""
action = AttachEnergyAction(
energy_card_id="zone-energy",
target_pokemon_id="nonexistent-pokemon",
from_energy_zone=True,
)
result = await engine.execute_action(game_with_energy_zone, "player1", action)
assert not result.success
# Energy should be returned to zone
p1 = game_with_energy_zone.players["player1"]
assert "zone-energy" in p1.energy_zone