Added stadium_owner_id field to GameState to track who played the stadium: - stadium_owner_id: str | None tracks the player who played the current stadium - When a stadium is replaced, old stadium discards to OWNER's pile (not current player) - Added stadium_owner_id to VisibleGameState for client visibility - Updated existing test and added 2 new tests for stadium ownership This fixes the bug where replacing an opponent's stadium would discard to the current player's pile instead of the opponent's. 797 tests passing.
2514 lines
81 KiB
Python
2514 lines
81 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,
|
|
StatusCondition,
|
|
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"
|
|
|
|
|
|
# =============================================================================
|
|
# Engine End Turn Knockout Tests (Issue #6 verification)
|
|
# =============================================================================
|
|
|
|
|
|
class TestEngineEndTurnKnockouts:
|
|
"""Tests verifying GameEngine.end_turn() processes status knockouts.
|
|
|
|
These tests verify Issue #6 from SYSTEM_REVIEW.md:
|
|
The engine's end_turn() should properly process knockouts from status
|
|
damage, including moving Pokemon to discard, awarding points, and
|
|
triggering win conditions.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def knockout_game(
|
|
self,
|
|
seeded_rng: SeededRandom,
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
) -> tuple[GameEngine, GameState]:
|
|
"""Create a game set up for knockout testing."""
|
|
engine = GameEngine(rules=RulesConfig(), rng=seeded_rng)
|
|
|
|
# Create decks with minimum required size (40 cards)
|
|
p1_deck = [
|
|
CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
p2_deck = [
|
|
CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
|
|
registry = {
|
|
basic_pokemon_def.id: basic_pokemon_def,
|
|
energy_def.id: energy_def,
|
|
}
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": p1_deck, "player2": p2_deck},
|
|
card_registry=registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Set up active Pokemon for both players
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
|
|
p1.bench.add(CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id))
|
|
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
|
|
|
|
# Start the game properly
|
|
engine.start_turn(game)
|
|
game.phase = TurnPhase.END
|
|
|
|
return engine, game
|
|
|
|
def test_engine_end_turn_processes_status_knockout(
|
|
self,
|
|
knockout_game: tuple[GameEngine, GameState],
|
|
basic_pokemon_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that engine.end_turn() processes status knockouts completely.
|
|
|
|
Verifies Issue #6: The engine should process knockouts from
|
|
TurnManager's end_turn result, not just return them in the result.
|
|
"""
|
|
engine, game = knockout_game
|
|
player = game.get_current_player()
|
|
active = player.get_active_pokemon()
|
|
|
|
# Set up for lethal poison damage
|
|
active.damage = 50
|
|
active.add_status(StatusCondition.POISONED)
|
|
|
|
result = engine.end_turn(game)
|
|
|
|
# Engine should successfully process the turn
|
|
assert result.success
|
|
# Pokemon should be in discard
|
|
assert "p1-active" in player.discard
|
|
# Active zone should be empty
|
|
assert len(player.active) == 0
|
|
|
|
def test_engine_end_turn_returns_win_result_on_knockout(
|
|
self,
|
|
knockout_game: tuple[GameEngine, GameState],
|
|
basic_pokemon_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that engine.end_turn() returns win result when knockout causes win.
|
|
|
|
If the status knockout triggers a win condition, the ActionResult
|
|
should contain the win_result.
|
|
"""
|
|
engine, game = knockout_game
|
|
player = game.get_current_player()
|
|
active = player.get_active_pokemon()
|
|
|
|
# Clear bench so knockout causes "no Pokemon" win
|
|
player.bench.cards.clear()
|
|
|
|
# Set up for lethal poison damage
|
|
active.damage = 50
|
|
active.add_status(StatusCondition.POISONED)
|
|
|
|
result = engine.end_turn(game)
|
|
|
|
# Should have win result
|
|
assert result.win_result is not None
|
|
assert result.win_result.winner_id == "player2"
|
|
assert result.win_result.end_reason == GameEndReason.NO_POKEMON
|
|
|
|
def test_engine_end_turn_awards_points_for_status_knockout(
|
|
self,
|
|
knockout_game: tuple[GameEngine, GameState],
|
|
basic_pokemon_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that engine.end_turn() awards points to opponent for status KO.
|
|
|
|
The full knockout flow through the engine should award points.
|
|
"""
|
|
engine, game = knockout_game
|
|
player = game.get_current_player()
|
|
opponent = game.players["player2"]
|
|
active = player.get_active_pokemon()
|
|
|
|
initial_score = opponent.score
|
|
|
|
# Set up for lethal poison damage
|
|
active.damage = 50
|
|
active.add_status(StatusCondition.POISONED)
|
|
|
|
engine.end_turn(game)
|
|
|
|
# Opponent should have gained 1 point
|
|
assert opponent.score == initial_score + 1
|
|
|
|
|
|
# =============================================================================
|
|
# SelectPrizeAction Tests (Issue #11)
|
|
# =============================================================================
|
|
|
|
|
|
class TestSelectPrizeAction:
|
|
"""Tests for SelectPrizeAction execution.
|
|
|
|
These tests verify Issue #11: SelectPrizeAction executor is implemented
|
|
and prize card mode works correctly.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def prize_game(
|
|
self,
|
|
seeded_rng: SeededRandom,
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
) -> tuple[GameEngine, GameState]:
|
|
"""Create a game with prize card mode enabled."""
|
|
from app.core.config import PrizeConfig
|
|
|
|
rules = RulesConfig()
|
|
rules.prizes = PrizeConfig(
|
|
count=6,
|
|
use_prize_cards=True,
|
|
prize_selection_random=False, # Player chooses prizes
|
|
)
|
|
engine = GameEngine(rules=rules, rng=seeded_rng)
|
|
|
|
# Create decks (need 40+ cards)
|
|
p1_deck = [
|
|
CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
p2_deck = [
|
|
CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
|
|
registry = {
|
|
basic_pokemon_def.id: basic_pokemon_def,
|
|
energy_def.id: energy_def,
|
|
}
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": p1_deck, "player2": p2_deck},
|
|
card_registry=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))
|
|
|
|
return engine, game
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_select_prize_adds_to_hand(
|
|
self,
|
|
prize_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that selecting a prize adds the card to hand.
|
|
|
|
Basic prize selection functionality.
|
|
"""
|
|
from app.core.models.actions import SelectPrizeAction
|
|
from app.core.models.game_state import ForcedAction
|
|
|
|
engine, game = prize_game
|
|
player = game.players["player1"]
|
|
|
|
# Ensure player has prizes
|
|
assert len(player.prizes) > 0
|
|
|
|
# Start turn (this draws a card), then record hand size
|
|
engine.start_turn(game)
|
|
initial_hand_size = len(player.hand)
|
|
initial_prize_count = len(player.prizes)
|
|
|
|
# Set up forced action (as if a knockout happened)
|
|
game.add_forced_action(
|
|
ForcedAction(
|
|
player_id="player1",
|
|
action_type="select_prize",
|
|
reason="Select a prize card",
|
|
params={"count": 1},
|
|
)
|
|
)
|
|
|
|
result = await engine.execute_action(game, "player1", SelectPrizeAction(prize_index=0))
|
|
|
|
assert result.success
|
|
assert len(player.hand) == initial_hand_size + 1
|
|
assert len(player.prizes) == initial_prize_count - 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_select_prize_invalid_index(
|
|
self,
|
|
prize_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that invalid prize index is rejected.
|
|
|
|
Validation should catch out-of-bounds index.
|
|
"""
|
|
from app.core.models.actions import SelectPrizeAction
|
|
|
|
engine, game = prize_game
|
|
engine.start_turn(game)
|
|
|
|
# Try to select prize at invalid index
|
|
result = await engine.execute_action(game, "player1", SelectPrizeAction(prize_index=99))
|
|
|
|
# Should fail validation
|
|
assert not result.success
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_select_prize_triggers_win(
|
|
self,
|
|
prize_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that taking the last prize triggers a win.
|
|
|
|
Prize card mode win condition.
|
|
"""
|
|
from app.core.models.actions import SelectPrizeAction
|
|
from app.core.models.game_state import ForcedAction
|
|
|
|
engine, game = prize_game
|
|
player = game.players["player1"]
|
|
|
|
# Remove all but one prize
|
|
while len(player.prizes) > 1:
|
|
player.prizes.cards.pop()
|
|
|
|
engine.start_turn(game)
|
|
# Set up forced action for prize selection
|
|
game.add_forced_action(
|
|
ForcedAction(
|
|
player_id="player1",
|
|
action_type="select_prize",
|
|
reason="Select your last prize card",
|
|
params={"count": 1},
|
|
)
|
|
)
|
|
|
|
result = await engine.execute_action(game, "player1", SelectPrizeAction(prize_index=0))
|
|
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# Turn Limit Check Tests (Issue #15)
|
|
# =============================================================================
|
|
|
|
|
|
class TestTurnLimitCheck:
|
|
"""Tests verifying turn limit is checked at turn start.
|
|
|
|
These tests verify Issue #15: start_turn() checks turn limit before
|
|
proceeding with the turn.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def turn_limit_game(
|
|
self,
|
|
seeded_rng: SeededRandom,
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
) -> tuple[GameEngine, GameState]:
|
|
"""Create a game with turn limit enabled."""
|
|
from app.core.config import WinConditionsConfig
|
|
|
|
rules = RulesConfig()
|
|
rules.win_conditions = WinConditionsConfig(
|
|
turn_limit_enabled=True,
|
|
turn_limit=10,
|
|
)
|
|
engine = GameEngine(rules=rules, rng=seeded_rng)
|
|
|
|
# Create decks
|
|
p1_deck = [
|
|
CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
p2_deck = [
|
|
CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
|
|
registry = {
|
|
basic_pokemon_def.id: basic_pokemon_def,
|
|
energy_def.id: energy_def,
|
|
}
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": p1_deck, "player2": p2_deck},
|
|
card_registry=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))
|
|
|
|
return engine, game
|
|
|
|
def test_start_turn_turn_limit_ends_game(
|
|
self,
|
|
turn_limit_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that start_turn ends game when turn limit is exceeded.
|
|
|
|
Verifies Issue #15: turn limit is checked before turn starts.
|
|
When one player has a higher score, they win with TURN_LIMIT reason.
|
|
"""
|
|
engine, game = turn_limit_game
|
|
|
|
# Give player1 a score advantage
|
|
game.players["player1"].score = 2
|
|
game.players["player2"].score = 1
|
|
|
|
# Set turn number past limit
|
|
game.turn_number = 11 # Limit is 10
|
|
|
|
result = engine.start_turn(game)
|
|
|
|
assert not result.success
|
|
assert result.win_result is not None
|
|
assert result.win_result.end_reason == GameEndReason.TURN_LIMIT
|
|
assert result.win_result.winner_id == "player1"
|
|
|
|
def test_start_turn_turn_limit_winner_by_score(
|
|
self,
|
|
turn_limit_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that higher score wins when turn limit is reached.
|
|
|
|
Standard turn limit resolution - higher score wins.
|
|
"""
|
|
engine, game = turn_limit_game
|
|
|
|
# Set scores
|
|
game.players["player1"].score = 3
|
|
game.players["player2"].score = 5
|
|
|
|
# Set turn number past limit
|
|
game.turn_number = 11
|
|
|
|
result = engine.start_turn(game)
|
|
|
|
assert not result.success
|
|
assert result.win_result is not None
|
|
assert result.win_result.winner_id == "player2" # Higher score
|
|
assert result.win_result.loser_id == "player1"
|
|
|
|
def test_start_turn_turn_limit_not_exceeded(
|
|
self,
|
|
turn_limit_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that turn proceeds normally when limit not exceeded.
|
|
|
|
Game should continue if turn number is within limit.
|
|
"""
|
|
engine, game = turn_limit_game
|
|
|
|
# Set turn number within limit
|
|
game.turn_number = 5
|
|
|
|
result = engine.start_turn(game)
|
|
|
|
assert result.success
|
|
assert result.win_result is None
|
|
|
|
def test_start_turn_turn_limit_disabled(
|
|
self,
|
|
seeded_rng: SeededRandom,
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that turn limit is not checked when disabled.
|
|
|
|
Game should continue past "limit" if feature is disabled.
|
|
"""
|
|
from app.core.config import WinConditionsConfig
|
|
|
|
rules = RulesConfig()
|
|
rules.win_conditions = WinConditionsConfig(
|
|
turn_limit_enabled=False,
|
|
turn_limit=10,
|
|
)
|
|
engine = GameEngine(rules=rules, rng=seeded_rng)
|
|
|
|
# Create game
|
|
p1_deck = [
|
|
CardInstance(instance_id=f"p1-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
p2_deck = [
|
|
CardInstance(instance_id=f"p2-deck-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(40)
|
|
]
|
|
|
|
registry = {
|
|
basic_pokemon_def.id: basic_pokemon_def,
|
|
energy_def.id: energy_def,
|
|
}
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": p1_deck, "player2": p2_deck},
|
|
card_registry=registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Set up active Pokemon
|
|
game.players["player1"].active.add(
|
|
CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
|
|
)
|
|
game.players["player2"].active.add(
|
|
CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
)
|
|
|
|
# Set turn number way past "limit"
|
|
game.turn_number = 100
|
|
|
|
start_result = engine.start_turn(game)
|
|
|
|
# Should succeed - limit is disabled
|
|
assert start_result.success
|
|
assert start_result.win_result is None
|
|
|
|
def test_start_turn_turn_limit_draw(
|
|
self,
|
|
turn_limit_game: tuple[GameEngine, GameState],
|
|
):
|
|
"""
|
|
Test that equal scores result in a draw when turn limit is reached.
|
|
|
|
When both players have the same score at turn limit, the game
|
|
ends in a draw with DRAW end reason.
|
|
"""
|
|
engine, game = turn_limit_game
|
|
|
|
# Set equal scores
|
|
game.players["player1"].score = 3
|
|
game.players["player2"].score = 3
|
|
|
|
# Set turn number past limit
|
|
game.turn_number = 11 # Limit is 10
|
|
|
|
result = engine.start_turn(game)
|
|
|
|
assert not result.success
|
|
assert result.win_result is not None
|
|
assert result.win_result.end_reason == GameEndReason.DRAW
|
|
assert result.win_result.winner_id == "" # No winner in a draw
|
|
|
|
|
|
# =============================================================================
|
|
# 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 (owned by player2)
|
|
old_stadium = CardInstance(instance_id="old-stadium", definition_id=stadium_card_def.id)
|
|
game.stadium_in_play = old_stadium
|
|
game.stadium_owner_id = "player2" # player2 owns the existing 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"
|
|
# New stadium should be owned by player1
|
|
assert game.stadium_owner_id == "player1"
|
|
# Old stadium should be discarded to its OWNER's discard (player2)
|
|
p2 = game.players["player2"]
|
|
assert "old-stadium" in p2.discard
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_play_stadium_replace_own_stadium(
|
|
self,
|
|
engine: GameEngine,
|
|
game_for_trainer: tuple[GameState, dict],
|
|
stadium_card_def: CardDefinition,
|
|
different_stadium_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that replacing own stadium discards to own pile.
|
|
|
|
When a player replaces their own stadium, the old one goes to
|
|
their own discard pile (not the opponent's).
|
|
"""
|
|
game, _ = game_for_trainer
|
|
game.card_registry[different_stadium_def.id] = different_stadium_def
|
|
p1 = game.players["player1"]
|
|
|
|
# Put player1's stadium in play
|
|
old_stadium = CardInstance(instance_id="old-stadium", definition_id=stadium_card_def.id)
|
|
game.stadium_in_play = old_stadium
|
|
game.stadium_owner_id = "player1" # player1 owns the existing stadium
|
|
|
|
# Add a different stadium to hand
|
|
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
|
|
assert game.stadium_in_play.instance_id == "new-stadium"
|
|
assert game.stadium_owner_id == "player1"
|
|
# Old stadium goes to player1's discard (the owner)
|
|
assert "old-stadium" in p1.discard
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_play_first_stadium_sets_owner(
|
|
self,
|
|
engine: GameEngine,
|
|
game_for_trainer: tuple[GameState, dict],
|
|
stadium_card_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that playing the first stadium sets the owner.
|
|
|
|
When no stadium is in play, playing one sets both the
|
|
stadium_in_play and stadium_owner_id.
|
|
"""
|
|
game, _ = game_for_trainer
|
|
p1 = game.players["player1"]
|
|
|
|
# No stadium in play initially
|
|
assert game.stadium_in_play is None
|
|
assert game.stadium_owner_id is None
|
|
|
|
# Add stadium to hand
|
|
stadium = CardInstance(instance_id="first-stadium", definition_id=stadium_card_def.id)
|
|
p1.hand.add(stadium)
|
|
|
|
action = PlayTrainerAction(card_instance_id="first-stadium")
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
assert game.stadium_in_play.instance_id == "first-stadium"
|
|
assert game.stadium_owner_id == "player1"
|
|
|
|
@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 (ability index 0)
|
|
active = game.players["player1"].get_active_pokemon()
|
|
assert active.get_ability_uses(0) >= 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.add_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 not game_with_forced_action.has_forced_action()
|
|
|
|
@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
|