- Add DamageCalculationResult model for transparent damage breakdown - Implement _calculate_attack_damage with W/R modifiers (additive/multiplicative) - Add _execute_attack_effect for future effect system integration - Add _build_attack_message for detailed damage breakdown in messages - Update _execute_attack to use new calculation pipeline - Bulbasaur now properly weak to Lightning in walkthrough demo New features: - Weakness applies bonus damage (additive +X or multiplicative xN) - Resistance reduces damage (minimum 0) - State changes include weakness/resistance details for UI - Messages show damage breakdown (e.g. 'base 10 +20 weakness') Tests: 7 new tests covering additive/multiplicative W/R, type matching, minimum damage floor, knockout triggers, and state change details
3263 lines
107 KiB
Python
3263 lines
107 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.enums import (
|
|
CardType,
|
|
EnergyType,
|
|
GameEndReason,
|
|
PokemonStage,
|
|
PokemonVariant,
|
|
StatusCondition,
|
|
TrainerType,
|
|
TurnPhase,
|
|
)
|
|
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.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
|
|
|
|
|
|
# =============================================================================
|
|
# Confusion Status - Attack Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestConfusionAttack:
|
|
"""Tests for confusion status during attack execution.
|
|
|
|
When a confused Pokemon attacks, it must flip a coin:
|
|
- Heads: attack proceeds normally
|
|
- Tails: attack fails and Pokemon damages itself
|
|
|
|
These tests verify the engine correctly handles both outcomes,
|
|
including edge cases like self-KO from confusion damage.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confused_attack_heads_proceeds_normally(
|
|
self,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that a confused Pokemon's attack proceeds normally on heads.
|
|
|
|
When the confusion coin flip is heads, the attack should execute
|
|
as if the Pokemon was not confused, dealing normal damage to defender.
|
|
"""
|
|
from app.core.rng import SeededRandom
|
|
|
|
# Create engine - we'll replace RNG after game creation
|
|
engine = GameEngine()
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Replace RNG with one that will flip heads (seed=1 produces heads)
|
|
engine.rng = SeededRandom(seed=1)
|
|
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
|
|
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
|
|
attacker.attach_energy(energy)
|
|
p1.active.add(attacker)
|
|
|
|
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
p2.active.add(defender)
|
|
|
|
attacker.add_status(StatusCondition.CONFUSED)
|
|
game.current_player_id = "player1" # Set player1's turn
|
|
game.phase = TurnPhase.ATTACK
|
|
game.turn_number = 2
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
assert "heads" in result.message.lower()
|
|
# Defender should have taken damage
|
|
assert defender.damage > 0
|
|
# Attacker should not have self-damage
|
|
assert attacker.damage == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confused_attack_tails_fails_with_self_damage(
|
|
self,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that a confused Pokemon damages itself on tails.
|
|
|
|
When the confusion coin flip is tails, the attack should fail
|
|
and the Pokemon should deal self-damage (default 30).
|
|
"""
|
|
from app.core.rng import SeededRandom
|
|
|
|
# Create engine - we'll replace RNG after game creation
|
|
engine = GameEngine()
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Replace RNG with one that will flip tails (seed=0 produces tails)
|
|
engine.rng = SeededRandom(seed=0)
|
|
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
|
|
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
|
|
attacker.attach_energy(energy)
|
|
p1.active.add(attacker)
|
|
|
|
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
p2.active.add(defender)
|
|
|
|
attacker.add_status(StatusCondition.CONFUSED)
|
|
game.current_player_id = "player1" # Set player1's turn
|
|
game.phase = TurnPhase.ATTACK
|
|
game.turn_number = 2
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
# Action still succeeds (coin was flipped), but attack failed
|
|
assert result.success
|
|
assert "tails" in result.message.lower()
|
|
assert "self-damage" in result.message.lower() or "30" in result.message
|
|
# Attacker should have self-damage (default 30)
|
|
assert attacker.damage == 30
|
|
# Defender should NOT have taken damage
|
|
assert defender.damage == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confused_attack_tails_self_ko(
|
|
self,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that confusion self-damage can knock out the attacker.
|
|
|
|
If the attacker has low HP remaining, the 30 self-damage from
|
|
confusion can knock it out. The opponent should receive points/prizes.
|
|
"""
|
|
from app.core.rng import SeededRandom
|
|
|
|
# Create engine - we'll replace RNG after game creation
|
|
engine = GameEngine()
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Replace RNG with one that will flip tails (seed=0 produces tails)
|
|
engine.rng = SeededRandom(seed=0)
|
|
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
|
|
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
|
|
attacker.attach_energy(energy)
|
|
# Give attacker damage so self-damage will KO it (60 HP Pokemon)
|
|
attacker.damage = 40 # 40 + 30 self-damage = 70 > 60 HP
|
|
p1.active.add(attacker)
|
|
|
|
# Add bench Pokemon so game doesn't end immediately
|
|
bench = CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id)
|
|
p1.bench.add(bench)
|
|
|
|
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
p2.active.add(defender)
|
|
|
|
attacker.add_status(StatusCondition.CONFUSED)
|
|
game.current_player_id = "player1" # Set player1's turn
|
|
game.phase = TurnPhase.ATTACK
|
|
game.turn_number = 2
|
|
|
|
initial_p2_score = p2.score
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
# Attacker should be knocked out (in discard, not in active)
|
|
assert p1.active.get(attacker.instance_id) is None
|
|
assert p1.discard.get(attacker.instance_id) is not None
|
|
# Opponent should have scored
|
|
assert p2.score > initial_p2_score
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confused_attack_uses_config_self_damage(
|
|
self,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that confusion self-damage is configurable via RulesConfig.
|
|
|
|
The self-damage amount should come from rules.status.confusion_self_damage.
|
|
"""
|
|
from app.core.config import RulesConfig, StatusConfig
|
|
from app.core.rng import SeededRandom
|
|
|
|
# Create engine - we'll replace RNG after game creation
|
|
engine = GameEngine()
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Replace RNG with one that will flip tails (seed=0 produces tails)
|
|
engine.rng = SeededRandom(seed=0)
|
|
|
|
# Custom rules with different confusion damage
|
|
game.rules = RulesConfig(status=StatusConfig(confusion_self_damage=50))
|
|
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
|
|
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
|
|
attacker.attach_energy(energy)
|
|
p1.active.add(attacker)
|
|
|
|
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
p2.active.add(defender)
|
|
|
|
attacker.add_status(StatusCondition.CONFUSED)
|
|
game.current_player_id = "player1" # Set player1's turn
|
|
game.phase = TurnPhase.ATTACK
|
|
game.turn_number = 2
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
# Attacker should have custom self-damage amount
|
|
assert attacker.damage == 50
|
|
|
|
|
|
# =============================================================================
|
|
# 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
|
|
|
|
|
|
# =============================================================================
|
|
# Weakness and Resistance Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestWeaknessResistance:
|
|
"""Tests for weakness and resistance damage calculations.
|
|
|
|
These tests verify that the engine correctly applies weakness and resistance
|
|
modifiers during attack damage calculation. Tests cover:
|
|
- Additive weakness (+X damage)
|
|
- Multiplicative weakness (xN damage)
|
|
- Additive resistance (-X damage)
|
|
- Multiplicative resistance (fractional damage)
|
|
- Combined weakness + resistance scenarios
|
|
- Type matching (only applies when types match)
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def lightning_attacker_def(self) -> CardDefinition:
|
|
"""Lightning-type attacker with a simple attack."""
|
|
return CardDefinition(
|
|
id="pikachu-test",
|
|
name="Pikachu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=60,
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[
|
|
Attack(name="Thunder Shock", damage=10, cost=[]),
|
|
],
|
|
retreat_cost=1,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def fire_attacker_def(self) -> CardDefinition:
|
|
"""Fire-type attacker with a simple attack."""
|
|
return CardDefinition(
|
|
id="charmander-test",
|
|
name="Charmander",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=70,
|
|
pokemon_type=EnergyType.FIRE,
|
|
attacks=[
|
|
Attack(name="Ember", damage=30, cost=[]),
|
|
],
|
|
retreat_cost=1,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def grass_weak_to_lightning_def(self) -> CardDefinition:
|
|
"""Grass Pokemon with additive Lightning weakness (+20)."""
|
|
from app.core.enums import ModifierMode
|
|
from app.core.models.card import WeaknessResistance
|
|
|
|
return CardDefinition(
|
|
id="bulbasaur-test",
|
|
name="Bulbasaur",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=70,
|
|
pokemon_type=EnergyType.GRASS,
|
|
attacks=[Attack(name="Vine Whip", damage=20, cost=[])],
|
|
weakness=WeaknessResistance(
|
|
energy_type=EnergyType.LIGHTNING,
|
|
mode=ModifierMode.ADDITIVE,
|
|
value=20,
|
|
),
|
|
retreat_cost=1,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def water_weak_to_lightning_x2_def(self) -> CardDefinition:
|
|
"""Water Pokemon with multiplicative Lightning weakness (x2)."""
|
|
from app.core.enums import ModifierMode
|
|
from app.core.models.card import WeaknessResistance
|
|
|
|
return CardDefinition(
|
|
id="squirtle-test",
|
|
name="Squirtle",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=60,
|
|
pokemon_type=EnergyType.WATER,
|
|
attacks=[Attack(name="Bubble", damage=20, cost=[])],
|
|
weakness=WeaknessResistance(
|
|
energy_type=EnergyType.LIGHTNING,
|
|
mode=ModifierMode.MULTIPLICATIVE,
|
|
value=2,
|
|
),
|
|
retreat_cost=1,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def grass_resists_water_def(self) -> CardDefinition:
|
|
"""Grass Pokemon with Water resistance (-30)."""
|
|
from app.core.enums import ModifierMode
|
|
from app.core.models.card import WeaknessResistance
|
|
|
|
return CardDefinition(
|
|
id="tangela-test",
|
|
name="Tangela",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=80,
|
|
pokemon_type=EnergyType.GRASS,
|
|
attacks=[Attack(name="Bind", damage=20, cost=[])],
|
|
resistance=WeaknessResistance(
|
|
energy_type=EnergyType.WATER,
|
|
mode=ModifierMode.ADDITIVE,
|
|
value=-30,
|
|
),
|
|
retreat_cost=2,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def grass_weak_and_resistant_def(self) -> CardDefinition:
|
|
"""Grass Pokemon with Fire weakness and Water resistance."""
|
|
from app.core.enums import ModifierMode
|
|
from app.core.models.card import WeaknessResistance
|
|
|
|
return CardDefinition(
|
|
id="oddish-test",
|
|
name="Oddish",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=50,
|
|
pokemon_type=EnergyType.GRASS,
|
|
attacks=[Attack(name="Absorb", damage=10, cost=[])],
|
|
weakness=WeaknessResistance(
|
|
energy_type=EnergyType.FIRE,
|
|
mode=ModifierMode.ADDITIVE,
|
|
value=20,
|
|
),
|
|
resistance=WeaknessResistance(
|
|
energy_type=EnergyType.WATER,
|
|
mode=ModifierMode.ADDITIVE,
|
|
value=-30,
|
|
),
|
|
retreat_cost=1,
|
|
)
|
|
|
|
@pytest.fixture
|
|
def energy_def(self) -> CardDefinition:
|
|
"""Basic Lightning energy."""
|
|
return CardDefinition(
|
|
id="lightning-energy",
|
|
name="Lightning Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.LIGHTNING,
|
|
energy_provides=[EnergyType.LIGHTNING],
|
|
)
|
|
|
|
def _create_battle_game(
|
|
self,
|
|
attacker_def: CardDefinition,
|
|
defender_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
) -> tuple[GameEngine, GameState]:
|
|
"""Helper to create a game ready for attack testing.
|
|
|
|
Sets up:
|
|
- Player1 has attacker as active, in attack phase
|
|
- Player2 has defender as active
|
|
- Registry with all card definitions
|
|
"""
|
|
rng = SeededRandom(seed=42)
|
|
engine = GameEngine(rules=RulesConfig(), rng=rng)
|
|
|
|
# Create card instances
|
|
attacker = CardInstance(instance_id="attacker-1", definition_id=attacker_def.id)
|
|
defender = CardInstance(instance_id="defender-1", definition_id=defender_def.id)
|
|
|
|
# Create minimal decks (pad with attacker copies)
|
|
p1_deck = [
|
|
CardInstance(instance_id=f"p1-card-{i}", definition_id=attacker_def.id)
|
|
for i in range(40)
|
|
]
|
|
p2_deck = [
|
|
CardInstance(instance_id=f"p2-card-{i}", definition_id=defender_def.id)
|
|
for i in range(40)
|
|
]
|
|
p1_energy = [
|
|
CardInstance(instance_id=f"p1-energy-{i}", definition_id=energy_def.id)
|
|
for i in range(20)
|
|
]
|
|
p2_energy = [
|
|
CardInstance(instance_id=f"p2-energy-{i}", definition_id=energy_def.id)
|
|
for i in range(20)
|
|
]
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": p1_deck, "player2": p2_deck},
|
|
energy_decks={"player1": p1_energy, "player2": p2_energy},
|
|
card_registry={
|
|
attacker_def.id: attacker_def,
|
|
defender_def.id: defender_def,
|
|
energy_def.id: energy_def,
|
|
},
|
|
)
|
|
game = result.game
|
|
|
|
# Set up the battlefield
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
# Clear active zones and place our test Pokemon
|
|
p1.active.clear()
|
|
p2.active.clear()
|
|
p1.active.add(attacker)
|
|
p2.active.add(defender)
|
|
|
|
# Set game state for attack
|
|
game.phase = TurnPhase.ATTACK
|
|
game.current_player_id = "player1"
|
|
game.turn_number = 1
|
|
|
|
return engine, game
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weakness_additive_increases_damage(
|
|
self,
|
|
lightning_attacker_def: CardDefinition,
|
|
grass_weak_to_lightning_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that additive weakness (+20) correctly increases damage.
|
|
|
|
Setup: Pikachu (Lightning) attacks Bulbasaur (weak to Lightning +20)
|
|
Attack: Thunder Shock (10 damage)
|
|
Expected: 10 base + 20 weakness = 30 damage
|
|
"""
|
|
engine, game = self._create_battle_game(
|
|
lightning_attacker_def,
|
|
grass_weak_to_lightning_def,
|
|
energy_def,
|
|
)
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
defender = game.players["player2"].get_active_pokemon()
|
|
assert defender.damage == 30 # 10 base + 20 weakness
|
|
assert "weakness" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weakness_multiplicative_doubles_damage(
|
|
self,
|
|
lightning_attacker_def: CardDefinition,
|
|
water_weak_to_lightning_x2_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that multiplicative weakness (x2) correctly doubles damage.
|
|
|
|
Setup: Pikachu (Lightning) attacks Squirtle (weak to Lightning x2)
|
|
Attack: Thunder Shock (10 damage)
|
|
Expected: 10 base x 2 = 20 damage
|
|
"""
|
|
engine, game = self._create_battle_game(
|
|
lightning_attacker_def,
|
|
water_weak_to_lightning_x2_def,
|
|
energy_def,
|
|
)
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
defender = game.players["player2"].get_active_pokemon()
|
|
assert defender.damage == 20 # 10 base x 2 weakness
|
|
assert "weakness" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resistance_reduces_damage(
|
|
self,
|
|
energy_def: CardDefinition,
|
|
grass_resists_water_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that additive resistance (-30) correctly reduces damage.
|
|
|
|
Setup: Water attacker attacks Tangela (resists Water -30)
|
|
Attack: 30 damage
|
|
Expected: 30 base - 30 resistance = 0 damage (minimum 0)
|
|
"""
|
|
|
|
# Create a Water attacker
|
|
water_attacker_def = CardDefinition(
|
|
id="psyduck-test",
|
|
name="Psyduck",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=50,
|
|
pokemon_type=EnergyType.WATER,
|
|
attacks=[Attack(name="Water Gun", damage=30, cost=[])],
|
|
retreat_cost=1,
|
|
)
|
|
|
|
engine, game = self._create_battle_game(
|
|
water_attacker_def,
|
|
grass_resists_water_def,
|
|
energy_def,
|
|
)
|
|
# Add water attacker to registry
|
|
game.card_registry[water_attacker_def.id] = water_attacker_def
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
defender = game.players["player2"].get_active_pokemon()
|
|
assert defender.damage == 0 # 30 base - 30 resistance = 0 (minimum)
|
|
assert "resistance" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_weakness_normal_damage(
|
|
self,
|
|
fire_attacker_def: CardDefinition,
|
|
grass_weak_to_lightning_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that no weakness is applied when types don't match.
|
|
|
|
Setup: Charmander (Fire) attacks Bulbasaur (weak to Lightning, not Fire)
|
|
Attack: Ember (30 damage)
|
|
Expected: 30 damage (no weakness bonus)
|
|
"""
|
|
engine, game = self._create_battle_game(
|
|
fire_attacker_def,
|
|
grass_weak_to_lightning_def,
|
|
energy_def,
|
|
)
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
defender = game.players["player2"].get_active_pokemon()
|
|
assert defender.damage == 30 # Just base damage, no weakness
|
|
assert "weakness" not in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_damage_minimum_zero(
|
|
self,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that damage cannot go below zero with resistance.
|
|
|
|
Setup: Attacker deals 10 damage, defender has -30 resistance
|
|
Expected: 0 damage (not negative)
|
|
"""
|
|
from app.core.enums import ModifierMode
|
|
from app.core.models.card import WeaknessResistance
|
|
|
|
# Create a weak attacker
|
|
weak_attacker_def = CardDefinition(
|
|
id="magikarp-test",
|
|
name="Magikarp",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=30,
|
|
pokemon_type=EnergyType.WATER,
|
|
attacks=[Attack(name="Splash", damage=10, cost=[])],
|
|
retreat_cost=1,
|
|
)
|
|
|
|
# Create defender with high resistance (using BASIC for simplicity)
|
|
high_resist_def = CardDefinition(
|
|
id="tangela-test-2",
|
|
name="Tangela",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=80,
|
|
pokemon_type=EnergyType.GRASS,
|
|
attacks=[Attack(name="Bind", damage=20, cost=[])],
|
|
resistance=WeaknessResistance(
|
|
energy_type=EnergyType.WATER,
|
|
mode=ModifierMode.ADDITIVE,
|
|
value=-30,
|
|
),
|
|
retreat_cost=2,
|
|
)
|
|
|
|
engine, game = self._create_battle_game(
|
|
weak_attacker_def,
|
|
high_resist_def,
|
|
energy_def,
|
|
)
|
|
game.card_registry[weak_attacker_def.id] = weak_attacker_def
|
|
game.card_registry[high_resist_def.id] = high_resist_def
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
defender = game.players["player2"].get_active_pokemon()
|
|
assert defender.damage == 0 # 10 - 30 = -20, but minimum 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_weakness_causes_knockout(
|
|
self,
|
|
lightning_attacker_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that weakness bonus damage can cause a knockout.
|
|
|
|
Setup: Pikachu (60 HP attacker) attacks 30 HP defender weak to Lightning (+20)
|
|
Attack: 10 damage + 20 weakness = 30 damage, should knock out 30 HP defender
|
|
"""
|
|
from app.core.enums import ModifierMode
|
|
from app.core.models.card import WeaknessResistance
|
|
|
|
# Create a low HP defender weak to Lightning
|
|
low_hp_defender_def = CardDefinition(
|
|
id="voltorb-test",
|
|
name="Voltorb",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
hp=30, # Will be KO'd by 30 damage
|
|
pokemon_type=EnergyType.LIGHTNING,
|
|
attacks=[Attack(name="Tackle", damage=10, cost=[])],
|
|
weakness=WeaknessResistance(
|
|
energy_type=EnergyType.LIGHTNING, # Weak to itself for test
|
|
mode=ModifierMode.ADDITIVE,
|
|
value=20,
|
|
),
|
|
retreat_cost=1,
|
|
)
|
|
|
|
engine, game = self._create_battle_game(
|
|
lightning_attacker_def,
|
|
low_hp_defender_def,
|
|
energy_def,
|
|
)
|
|
game.card_registry[low_hp_defender_def.id] = low_hp_defender_def
|
|
|
|
# Track initial score
|
|
initial_score = game.players["player1"].score
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
# Knockout should have happened - check via:
|
|
# 1. Message contains weakness
|
|
assert "weakness" in result.message.lower()
|
|
# 2. Attacker scored a point (knockout awards points)
|
|
assert game.players["player1"].score > initial_score
|
|
# 3. The attack state change shows final damage of 30
|
|
attack_change = next(
|
|
(sc for sc in result.state_changes if sc.get("type") == "attack"),
|
|
None,
|
|
)
|
|
assert attack_change is not None
|
|
assert attack_change["final_damage"] == 30 # 10 base + 20 weakness
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_changes_include_weakness_info(
|
|
self,
|
|
lightning_attacker_def: CardDefinition,
|
|
grass_weak_to_lightning_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that state_changes includes weakness/resistance information.
|
|
|
|
This is important for UI animations and logging.
|
|
"""
|
|
engine, game = self._create_battle_game(
|
|
lightning_attacker_def,
|
|
grass_weak_to_lightning_def,
|
|
energy_def,
|
|
)
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(game, "player1", action)
|
|
|
|
assert result.success
|
|
# Find the attack state change
|
|
attack_change = next(
|
|
(sc for sc in result.state_changes if sc.get("type") == "attack"),
|
|
None,
|
|
)
|
|
assert attack_change is not None
|
|
assert attack_change["base_damage"] == 10
|
|
assert attack_change["final_damage"] == 30
|
|
assert attack_change["weakness_applied"] is not None
|
|
assert attack_change["weakness_applied"]["type"] == "lightning"
|