mantimon-tcg/backend/tests/core/test_engine.py
Cal Corum 3f830b25b7 Add GameEngine orchestrator with full game lifecycle support
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)
2026-01-25 13:21:41 -06:00

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"