Bug fixes in engine.py: - PlayPokemonAction: card_id -> card_instance_id - PlayTrainerAction: card_id -> card_instance_id - UseAbilityAction: pokemon_card_id -> pokemon_id - SelectActiveAction: card_id -> pokemon_id - record_ability_use() -> ability_uses_this_turn += 1 Added 26 new tests covering: - Energy deck setup (Pokemon Pocket style) - Prize card mode - Deck size validation (too large) - PlayPokemonAction (to active, to bench, not found) - EvolvePokemonAction (success, not in hand, target not found) - PlayTrainerAction (item, supporter, stadium, replacement) - UseAbilityAction (success, not found, invalid index) - SelectActiveAction (forced action, not on bench) - Deck-out on turn start - Attack edge cases (no energy, invalid index) - Retreat without bench - Attach energy from energy zone Test count: 711 -> 737 (+26) Coverage: 89% -> 94% overall, engine.py 55% -> 81%
1915 lines
61 KiB
Python
1915 lines
61 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,
|
|
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,
|
|
evolves_from="pikachu-001",
|
|
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
|
|
active = ready_game.players["player1"].get_active_pokemon()
|
|
assert "p1-energy-hand" 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 - the energy must be in a zone so find_card_instance works
|
|
# Put it in discard pile (energy stays there after being attached for tracking)
|
|
p1 = ready_game.players["player1"]
|
|
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
|
p1.discard.add(energy) # Must be findable by find_card_instance
|
|
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
|
|
|
# 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
|
|
active = ready_game.players["player1"].get_active_pokemon()
|
|
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)
|
|
p1.discard.add(energy) # Must be findable
|
|
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
|
|
|
# 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,
|
|
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
|
|
active.attach_energy("attached-energy-1")
|
|
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 "attached-energy-1" in active.attached_energy
|
|
assert active.damage == 20
|
|
|
|
# Verify old Pokemon went to discard
|
|
assert "active-pikachu" in game.players["player1"].discard
|
|
|
|
@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,
|
|
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
|
|
p1 = game_with_energy_zone.players["player1"]
|
|
active = p1.get_active_pokemon()
|
|
assert "zone-energy" 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
|
|
bench_pokemon = p1.bench.get("p1-bench")
|
|
assert "zone-energy" 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
|