Implements the main public API for the core game engine: - create_game(): deck validation, shuffling, dealing hands - execute_action(): validates and executes all 11 action types - start_turn()/end_turn(): turn management integration - get_visible_state(): hidden info filtering for clients - handle_timeout(): timeout handling for turn limits Integrates turn_manager, rules_validator, win_conditions, and visibility filter into a cohesive orchestration layer. 22 integration tests covering game creation, action execution, visibility filtering, and error handling. 711 tests passing (29/32 tasks complete)
845 lines
26 KiB
Python
845 lines
26 KiB
Python
"""Integration tests for the GameEngine orchestrator.
|
|
|
|
This module tests the full game flow from creation through actions to win
|
|
conditions. These are integration tests that verify all components work
|
|
together correctly.
|
|
|
|
Test categories:
|
|
- Game creation and initialization
|
|
- Action validation through engine
|
|
- Action execution and state changes
|
|
- Turn management integration
|
|
- Win condition detection
|
|
- Full game playthrough scenarios
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app.core.config import RulesConfig
|
|
from app.core.engine import GameEngine
|
|
from app.core.models.actions import (
|
|
AttachEnergyAction,
|
|
AttackAction,
|
|
PassAction,
|
|
ResignAction,
|
|
RetreatAction,
|
|
)
|
|
from app.core.models.card import Attack, CardDefinition, CardInstance
|
|
from app.core.models.enums import (
|
|
CardType,
|
|
EnergyType,
|
|
GameEndReason,
|
|
PokemonStage,
|
|
PokemonVariant,
|
|
TurnPhase,
|
|
)
|
|
from app.core.models.game_state import GameState
|
|
from app.core.rng import SeededRandom
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_rng() -> SeededRandom:
|
|
"""Create a seeded RNG for deterministic tests."""
|
|
return SeededRandom(seed=42)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_pokemon_def() -> CardDefinition:
|
|
"""Create a basic Pokemon with an attack."""
|
|
return CardDefinition(
|
|
id="pikachu-001",
|
|
name="Pikachu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.BASIC,
|
|
variant=PokemonVariant.NORMAL,
|
|
hp=60,
|
|
attacks=[
|
|
Attack(
|
|
name="Thunder Shock",
|
|
damage=20,
|
|
cost=[EnergyType.LIGHTNING],
|
|
),
|
|
],
|
|
retreat_cost=1,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def strong_pokemon_def() -> CardDefinition:
|
|
"""Create a strong Pokemon for knockout tests."""
|
|
return CardDefinition(
|
|
id="raichu-001",
|
|
name="Raichu",
|
|
card_type=CardType.POKEMON,
|
|
stage=PokemonStage.STAGE_1,
|
|
variant=PokemonVariant.NORMAL,
|
|
hp=100,
|
|
evolves_from="pikachu-001",
|
|
attacks=[
|
|
Attack(
|
|
name="Thunder",
|
|
damage=80,
|
|
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING],
|
|
),
|
|
],
|
|
retreat_cost=2,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def energy_def() -> CardDefinition:
|
|
"""Create a basic energy card."""
|
|
return CardDefinition(
|
|
id="lightning-energy",
|
|
name="Lightning Energy",
|
|
card_type=CardType.ENERGY,
|
|
energy_type=EnergyType.LIGHTNING,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def card_registry(
|
|
basic_pokemon_def: CardDefinition,
|
|
strong_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
) -> dict[str, CardDefinition]:
|
|
"""Create a card registry with test cards."""
|
|
return {
|
|
basic_pokemon_def.id: basic_pokemon_def,
|
|
strong_pokemon_def.id: strong_pokemon_def,
|
|
energy_def.id: energy_def,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def player1_deck(
|
|
basic_pokemon_def: CardDefinition, energy_def: CardDefinition
|
|
) -> list[CardInstance]:
|
|
"""Create a deck for player 1."""
|
|
cards = []
|
|
# Add 10 basic Pokemon
|
|
for i in range(10):
|
|
cards.append(
|
|
CardInstance(instance_id=f"p1-pokemon-{i}", definition_id=basic_pokemon_def.id)
|
|
)
|
|
# Add 30 energy
|
|
for i in range(30):
|
|
cards.append(CardInstance(instance_id=f"p1-energy-{i}", definition_id=energy_def.id))
|
|
return cards
|
|
|
|
|
|
@pytest.fixture
|
|
def player2_deck(
|
|
basic_pokemon_def: CardDefinition, energy_def: CardDefinition
|
|
) -> list[CardInstance]:
|
|
"""Create a deck for player 2."""
|
|
cards = []
|
|
# Add 10 basic Pokemon
|
|
for i in range(10):
|
|
cards.append(
|
|
CardInstance(instance_id=f"p2-pokemon-{i}", definition_id=basic_pokemon_def.id)
|
|
)
|
|
# Add 30 energy
|
|
for i in range(30):
|
|
cards.append(CardInstance(instance_id=f"p2-energy-{i}", definition_id=energy_def.id))
|
|
return cards
|
|
|
|
|
|
@pytest.fixture
|
|
def engine(seeded_rng: SeededRandom) -> GameEngine:
|
|
"""Create a GameEngine with default rules and seeded RNG."""
|
|
return GameEngine(rules=RulesConfig(), rng=seeded_rng)
|
|
|
|
|
|
# =============================================================================
|
|
# Game Creation Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestGameCreation:
|
|
"""Tests for game creation and initialization."""
|
|
|
|
def test_create_game_success(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test successful game creation with valid inputs.
|
|
|
|
Verifies game is created with correct initial state.
|
|
"""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
assert result.success
|
|
assert result.game is not None
|
|
assert result.game.game_id is not None
|
|
assert len(result.game.players) == 2
|
|
assert result.game.turn_number == 1
|
|
assert result.game.phase == TurnPhase.SETUP
|
|
|
|
def test_create_game_deals_starting_hands(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test that game creation deals starting hands.
|
|
|
|
Each player should have cards in hand after creation.
|
|
"""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
assert result.success
|
|
game = result.game
|
|
assert len(game.players["player1"].hand) > 0
|
|
assert len(game.players["player2"].hand) > 0
|
|
|
|
def test_create_game_shuffles_decks(
|
|
self,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test that game creation shuffles decks differently with different seeds.
|
|
|
|
Verifies decks are actually shuffled and RNG affects order.
|
|
"""
|
|
engine1 = GameEngine(rng=SeededRandom(seed=1))
|
|
engine2 = GameEngine(rng=SeededRandom(seed=2))
|
|
|
|
result1 = engine1.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": list(player1_deck), "player2": list(player2_deck)},
|
|
card_registry=card_registry,
|
|
)
|
|
result2 = engine2.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": list(player1_deck), "player2": list(player2_deck)},
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
# Different seeds should result in different deck orders
|
|
deck1 = [c.instance_id for c in result1.game.players["player1"].deck.cards]
|
|
deck2 = [c.instance_id for c in result2.game.players["player1"].deck.cards]
|
|
assert deck1 != deck2
|
|
|
|
def test_create_game_wrong_player_count(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test that game creation fails with wrong player count.
|
|
"""
|
|
result = engine.create_game(
|
|
player_ids=["player1"], # Only 1 player
|
|
decks={"player1": player1_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
assert not result.success
|
|
assert "2 players" in result.message
|
|
|
|
def test_create_game_missing_deck(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test that game creation fails when a player has no deck.
|
|
"""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck}, # Missing player2's deck
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
assert not result.success
|
|
assert "player2" in result.message
|
|
|
|
def test_create_game_deck_too_small(
|
|
self,
|
|
engine: GameEngine,
|
|
basic_pokemon_def: CardDefinition,
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test that game creation fails with undersized deck.
|
|
"""
|
|
small_deck = [
|
|
CardInstance(instance_id=f"card-{i}", definition_id=basic_pokemon_def.id)
|
|
for i in range(10) # Too small
|
|
]
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": small_deck, "player2": small_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
assert not result.success
|
|
assert "too small" in result.message
|
|
|
|
def test_create_game_no_basic_pokemon(
|
|
self,
|
|
engine: GameEngine,
|
|
energy_def: CardDefinition,
|
|
card_registry: dict[str, CardDefinition],
|
|
):
|
|
"""
|
|
Test that game creation fails when deck has no Basic Pokemon.
|
|
"""
|
|
energy_only_deck = [
|
|
CardInstance(instance_id=f"energy-{i}", definition_id=energy_def.id) for i in range(40)
|
|
]
|
|
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": energy_only_deck, "player2": energy_only_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
|
|
assert not result.success
|
|
assert "Basic Pokemon" in result.message
|
|
|
|
|
|
# =============================================================================
|
|
# Action Validation Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestActionValidation:
|
|
"""Tests for action validation through the engine."""
|
|
|
|
@pytest.fixture
|
|
def active_game(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
) -> GameState:
|
|
"""Create a game and set up for play."""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Set up active Pokemon for both players
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
# Find a basic Pokemon in each hand and play to active
|
|
for card in list(p1.hand.cards):
|
|
card_def = card_registry.get(card.definition_id)
|
|
if card_def and card_def.is_basic_pokemon():
|
|
p1.hand.remove(card.instance_id)
|
|
p1.active.add(card)
|
|
break
|
|
|
|
for card in list(p2.hand.cards):
|
|
card_def = card_registry.get(card.definition_id)
|
|
if card_def and card_def.is_basic_pokemon():
|
|
p2.hand.remove(card.instance_id)
|
|
p2.active.add(card)
|
|
break
|
|
|
|
# Start the game
|
|
game.phase = TurnPhase.MAIN
|
|
return game
|
|
|
|
def test_validate_action_wrong_turn(
|
|
self,
|
|
engine: GameEngine,
|
|
active_game: GameState,
|
|
):
|
|
"""
|
|
Test that actions are rejected when it's not your turn.
|
|
"""
|
|
# It's player1's turn, player2 tries to act
|
|
action = PassAction()
|
|
result = engine.validate_action(active_game, "player2", action)
|
|
|
|
assert not result.valid
|
|
assert "Not your turn" in result.reason
|
|
|
|
def test_validate_resign_always_allowed(
|
|
self,
|
|
engine: GameEngine,
|
|
active_game: GameState,
|
|
):
|
|
"""
|
|
Test that resignation is allowed even on opponent's turn.
|
|
"""
|
|
action = ResignAction()
|
|
result = engine.validate_action(active_game, "player2", action)
|
|
|
|
assert result.valid
|
|
|
|
|
|
# =============================================================================
|
|
# Action Execution Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestActionExecution:
|
|
"""Tests for action execution through the engine."""
|
|
|
|
@pytest.fixture
|
|
def ready_game(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
energy_def: CardDefinition,
|
|
) -> GameState:
|
|
"""Create a game ready for action execution testing."""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Set up active and bench Pokemon
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
|
|
# Player 1: active + 1 bench + energy in hand
|
|
active1 = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
|
|
bench1 = CardInstance(instance_id="p1-bench-1", definition_id=basic_pokemon_def.id)
|
|
energy1 = CardInstance(instance_id="p1-energy-hand", definition_id=energy_def.id)
|
|
p1.active.add(active1)
|
|
p1.bench.add(bench1)
|
|
p1.hand.add(energy1)
|
|
|
|
# Player 2: active only
|
|
active2 = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
p2.active.add(active2)
|
|
|
|
game.phase = TurnPhase.MAIN
|
|
game.turn_number = 2 # Not first turn
|
|
return game
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_attach_energy(
|
|
self,
|
|
engine: GameEngine,
|
|
ready_game: GameState,
|
|
):
|
|
"""
|
|
Test executing an attach energy action.
|
|
"""
|
|
action = AttachEnergyAction(
|
|
energy_card_id="p1-energy-hand",
|
|
target_pokemon_id="p1-active",
|
|
from_energy_zone=False,
|
|
)
|
|
|
|
result = await engine.execute_action(ready_game, "player1", action)
|
|
|
|
assert result.success
|
|
assert "Energy attached" in result.message
|
|
|
|
# Verify energy is attached
|
|
active = ready_game.players["player1"].get_active_pokemon()
|
|
assert "p1-energy-hand" in active.attached_energy
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_attack(
|
|
self,
|
|
engine: GameEngine,
|
|
ready_game: GameState,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test executing an attack action.
|
|
"""
|
|
# Attach energy - the energy must be in a zone so find_card_instance works
|
|
# Put it in discard pile (energy stays there after being attached for tracking)
|
|
p1 = ready_game.players["player1"]
|
|
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
|
p1.discard.add(energy) # Must be findable by find_card_instance
|
|
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
|
|
|
# Need to be in ATTACK phase for attack action
|
|
ready_game.phase = TurnPhase.ATTACK
|
|
|
|
action = AttackAction(attack_index=0)
|
|
|
|
result = await engine.execute_action(ready_game, "player1", action)
|
|
|
|
assert result.success
|
|
assert "Thunder Shock" in result.message
|
|
assert "20 damage" in result.message
|
|
|
|
# Verify damage dealt
|
|
defender = ready_game.players["player2"].get_active_pokemon()
|
|
assert defender.damage == 20
|
|
|
|
# Phase should advance to END
|
|
assert ready_game.phase == TurnPhase.END
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_pass(
|
|
self,
|
|
engine: GameEngine,
|
|
ready_game: GameState,
|
|
):
|
|
"""
|
|
Test executing a pass action.
|
|
"""
|
|
action = PassAction()
|
|
|
|
result = await engine.execute_action(ready_game, "player1", action)
|
|
|
|
assert result.success
|
|
assert ready_game.phase == TurnPhase.END
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_resign(
|
|
self,
|
|
engine: GameEngine,
|
|
ready_game: GameState,
|
|
):
|
|
"""
|
|
Test executing a resignation.
|
|
"""
|
|
action = ResignAction()
|
|
|
|
result = await engine.execute_action(ready_game, "player1", action)
|
|
|
|
assert result.success
|
|
assert result.win_result is not None
|
|
assert result.win_result.winner_id == "player2"
|
|
assert result.win_result.end_reason == GameEndReason.RESIGNATION
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_retreat(
|
|
self,
|
|
engine: GameEngine,
|
|
ready_game: GameState,
|
|
):
|
|
"""
|
|
Test executing a retreat action.
|
|
"""
|
|
# Attach energy for retreat cost
|
|
active = ready_game.players["player1"].get_active_pokemon()
|
|
active.attach_energy("retreat-energy")
|
|
|
|
action = RetreatAction(
|
|
new_active_id="p1-bench-1",
|
|
energy_to_discard=["retreat-energy"],
|
|
)
|
|
|
|
result = await engine.execute_action(ready_game, "player1", action)
|
|
|
|
assert result.success
|
|
assert "Retreated" in result.message
|
|
|
|
# Verify Pokemon swapped
|
|
new_active = ready_game.players["player1"].get_active_pokemon()
|
|
assert new_active.instance_id == "p1-bench-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_invalid_action_fails(
|
|
self,
|
|
engine: GameEngine,
|
|
ready_game: GameState,
|
|
):
|
|
"""
|
|
Test that invalid actions return failure.
|
|
"""
|
|
# Try to attach non-existent energy
|
|
action = AttachEnergyAction(
|
|
energy_card_id="nonexistent-energy",
|
|
target_pokemon_id="p1-active",
|
|
from_energy_zone=False,
|
|
)
|
|
|
|
result = await engine.execute_action(ready_game, "player1", action)
|
|
|
|
assert not result.success
|
|
|
|
|
|
# =============================================================================
|
|
# Turn Management Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestTurnManagement:
|
|
"""Tests for turn management through the engine."""
|
|
|
|
@pytest.fixture
|
|
def game_at_start(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
) -> GameState:
|
|
"""Create a game at the start of a turn."""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Set up active Pokemon
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
|
|
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
|
|
|
|
game.phase = TurnPhase.SETUP
|
|
game.turn_number = 1
|
|
return game
|
|
|
|
def test_start_turn(
|
|
self,
|
|
engine: GameEngine,
|
|
game_at_start: GameState,
|
|
):
|
|
"""
|
|
Test starting a turn through the engine.
|
|
"""
|
|
result = engine.start_turn(game_at_start)
|
|
|
|
assert result.success
|
|
assert game_at_start.phase == TurnPhase.MAIN
|
|
|
|
def test_end_turn(
|
|
self,
|
|
engine: GameEngine,
|
|
game_at_start: GameState,
|
|
):
|
|
"""
|
|
Test ending a turn through the engine.
|
|
"""
|
|
game_at_start.phase = TurnPhase.END
|
|
original_player = game_at_start.current_player_id
|
|
|
|
result = engine.end_turn(game_at_start)
|
|
|
|
assert result.success
|
|
assert game_at_start.current_player_id != original_player
|
|
|
|
|
|
# =============================================================================
|
|
# Win Condition Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestWinConditions:
|
|
"""Tests for win condition detection through the engine."""
|
|
|
|
@pytest.fixture
|
|
def near_win_game(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
) -> GameState:
|
|
"""Create a game where player1 is close to winning."""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
|
|
# Player 1 has 3 points (needs 4 to win)
|
|
game.players["player1"].score = 3
|
|
|
|
# Set up active Pokemon
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
|
|
|
|
# Player 2 active has 50 damage (60 HP, 20 more will KO)
|
|
p2_active = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
|
|
p2_active.damage = 50
|
|
p2.active.add(p2_active)
|
|
|
|
game.phase = TurnPhase.MAIN
|
|
game.turn_number = 5
|
|
return game
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_knockout_triggers_win(
|
|
self,
|
|
engine: GameEngine,
|
|
near_win_game: GameState,
|
|
energy_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test that a knockout that reaches win threshold ends the game.
|
|
"""
|
|
# Attack will deal 20 damage, which KOs the defender (50 + 20 = 70 > 60)
|
|
# This gives player1 their 4th point, winning the game
|
|
p1 = near_win_game.players["player1"]
|
|
energy = CardInstance(instance_id="attack-energy", definition_id=energy_def.id)
|
|
p1.discard.add(energy) # Must be findable
|
|
p1.get_active_pokemon().attach_energy(energy.instance_id)
|
|
|
|
# Need to be in ATTACK phase
|
|
near_win_game.phase = TurnPhase.ATTACK
|
|
|
|
action = AttackAction(attack_index=0)
|
|
result = await engine.execute_action(near_win_game, "player1", action)
|
|
|
|
assert result.success
|
|
assert result.win_result is not None
|
|
assert result.win_result.winner_id == "player1"
|
|
assert result.win_result.end_reason == GameEndReason.PRIZES_TAKEN
|
|
|
|
def test_timeout_ends_game(
|
|
self,
|
|
engine: GameEngine,
|
|
near_win_game: GameState,
|
|
):
|
|
"""
|
|
Test that timeout triggers win for opponent.
|
|
"""
|
|
result = engine.handle_timeout(near_win_game, "player1")
|
|
|
|
assert result.success
|
|
assert result.win_result is not None
|
|
assert result.win_result.winner_id == "player2"
|
|
assert result.win_result.end_reason == GameEndReason.TIMEOUT
|
|
|
|
|
|
# =============================================================================
|
|
# Visibility Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestVisibility:
|
|
"""Tests for visibility filtering through the engine."""
|
|
|
|
@pytest.fixture
|
|
def active_game(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
) -> GameState:
|
|
"""Create an active game for visibility tests."""
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
return result.game
|
|
|
|
def test_get_visible_state(
|
|
self,
|
|
engine: GameEngine,
|
|
active_game: GameState,
|
|
):
|
|
"""
|
|
Test getting a visible state through the engine.
|
|
"""
|
|
visible = engine.get_visible_state(active_game, "player1")
|
|
|
|
assert visible.viewer_id == "player1"
|
|
assert visible.game_id == active_game.game_id
|
|
assert len(visible.players) == 2
|
|
|
|
def test_get_spectator_state(
|
|
self,
|
|
engine: GameEngine,
|
|
active_game: GameState,
|
|
):
|
|
"""
|
|
Test getting a spectator state through the engine.
|
|
"""
|
|
visible = engine.get_spectator_state(active_game)
|
|
|
|
assert visible.viewer_id == "__spectator__"
|
|
# No hands should be visible
|
|
for player_state in visible.players.values():
|
|
assert len(player_state.hand.cards) == 0
|
|
|
|
|
|
# =============================================================================
|
|
# Integration Scenario Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestIntegrationScenarios:
|
|
"""Full game scenario tests."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_turn_cycle(
|
|
self,
|
|
engine: GameEngine,
|
|
player1_deck: list[CardInstance],
|
|
player2_deck: list[CardInstance],
|
|
card_registry: dict[str, CardDefinition],
|
|
basic_pokemon_def: CardDefinition,
|
|
):
|
|
"""
|
|
Test a complete turn cycle: create game -> start turn -> actions -> end turn.
|
|
"""
|
|
# Create game
|
|
result = engine.create_game(
|
|
player_ids=["player1", "player2"],
|
|
decks={"player1": player1_deck, "player2": player2_deck},
|
|
card_registry=card_registry,
|
|
)
|
|
game = result.game
|
|
assert result.success
|
|
|
|
# Set up active Pokemon
|
|
p1 = game.players["player1"]
|
|
p2 = game.players["player2"]
|
|
p1.active.add(CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id))
|
|
p2.active.add(CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id))
|
|
|
|
# Start turn
|
|
start_result = engine.start_turn(game)
|
|
assert start_result.success
|
|
assert game.phase == TurnPhase.MAIN
|
|
|
|
# Execute pass action
|
|
pass_result = await engine.execute_action(game, game.current_player_id, PassAction())
|
|
assert pass_result.success
|
|
assert game.phase == TurnPhase.END
|
|
|
|
# End turn
|
|
end_result = engine.end_turn(game)
|
|
assert end_result.success
|
|
|
|
# Verify turn advanced
|
|
assert game.current_player_id == "player2"
|