Implement GameService.create_game (GS-002)
- Add create_game method that loads decks via DeckService, converts CardDefinitions to CardInstances, and persists to Redis/Postgres - Build card registry from only the cards in play (not all cards) - Add GameCreationError exception and GameCreateResult dataclass - Add creation_engine_factory for DI-based testing (no monkey patching) - Add helper methods: _cards_to_instances, _build_card_registry - Update tests with proper mocks for success, deck failure, engine failure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f93f5b617a
commit
3c75ee0e00
@ -35,26 +35,38 @@ Example:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid as uuid_module
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.core.engine import ActionResult, GameEngine
|
if TYPE_CHECKING:
|
||||||
|
from app.services.deck_service import DeckService
|
||||||
|
|
||||||
|
from app.core.config import RulesConfig
|
||||||
|
from app.core.engine import ActionResult, GameCreationResult, GameEngine
|
||||||
from app.core.enums import GameEndReason
|
from app.core.enums import GameEndReason
|
||||||
from app.core.models.actions import Action, ResignAction
|
from app.core.models.actions import Action, ResignAction
|
||||||
|
from app.core.models.card import CardInstance
|
||||||
from app.core.models.game_state import GameState
|
from app.core.models.game_state import GameState
|
||||||
from app.core.rng import create_rng
|
from app.core.rng import create_rng
|
||||||
from app.core.visibility import VisibleGameState, get_visible_state
|
from app.core.visibility import VisibleGameState, get_visible_state
|
||||||
|
from app.db.models.game import GameType
|
||||||
from app.services.card_service import CardService, get_card_service
|
from app.services.card_service import CardService, get_card_service
|
||||||
from app.services.game_state_manager import GameStateManager, game_state_manager
|
from app.services.game_state_manager import GameStateManager, game_state_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Type alias for engine factory - takes GameState, returns GameEngine
|
# Type alias for engine factory - takes GameState, returns GameEngine (for execute_action)
|
||||||
EngineFactory = Callable[[GameState], GameEngine]
|
EngineFactory = Callable[[GameState], GameEngine]
|
||||||
|
|
||||||
|
# Type alias for creation factory - takes RulesConfig, returns GameEngine (for create_game)
|
||||||
|
CreationEngineFactory = Callable[[RulesConfig], GameEngine]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Exceptions
|
# Exceptions
|
||||||
@ -114,6 +126,14 @@ class GameAlreadyEndedError(GameServiceError):
|
|||||||
super().__init__(f"Game {game_id} has already ended")
|
super().__init__(f"Game {game_id} has already ended")
|
||||||
|
|
||||||
|
|
||||||
|
class GameCreationError(GameServiceError):
|
||||||
|
"""Raised when game creation fails."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str) -> None:
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"Failed to create game: {reason}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Result Types
|
# Result Types
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -165,6 +185,27 @@ class GameJoinResult:
|
|||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameCreateResult:
|
||||||
|
"""Result of creating a new game.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
success: Whether the game was created successfully.
|
||||||
|
game_id: The created game's ID (None if failed).
|
||||||
|
player1_view: Initial state visible to player 1.
|
||||||
|
player2_view: Initial state visible to player 2.
|
||||||
|
starting_player_id: Which player goes first.
|
||||||
|
message: Description or error message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
game_id: str | None = None
|
||||||
|
player1_view: VisibleGameState | None = None
|
||||||
|
player2_view: VisibleGameState | None = None
|
||||||
|
starting_player_id: str | None = None
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GameService
|
# GameService
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -184,7 +225,8 @@ class GameService:
|
|||||||
Attributes:
|
Attributes:
|
||||||
_state_manager: GameStateManager for persistence.
|
_state_manager: GameStateManager for persistence.
|
||||||
_card_service: CardService for card definitions.
|
_card_service: CardService for card definitions.
|
||||||
_engine_factory: Factory function to create GameEngine instances.
|
_engine_factory: Factory for creating GameEngine for action execution.
|
||||||
|
_creation_engine_factory: Factory for creating GameEngine for game creation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -192,6 +234,7 @@ class GameService:
|
|||||||
state_manager: GameStateManager | None = None,
|
state_manager: GameStateManager | None = None,
|
||||||
card_service: CardService | None = None,
|
card_service: CardService | None = None,
|
||||||
engine_factory: EngineFactory | None = None,
|
engine_factory: EngineFactory | None = None,
|
||||||
|
creation_engine_factory: CreationEngineFactory | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the GameService.
|
"""Initialize the GameService.
|
||||||
|
|
||||||
@ -202,13 +245,19 @@ class GameService:
|
|||||||
Args:
|
Args:
|
||||||
state_manager: GameStateManager instance. Uses global if not provided.
|
state_manager: GameStateManager instance. Uses global if not provided.
|
||||||
card_service: CardService instance. Uses global if not provided.
|
card_service: CardService instance. Uses global if not provided.
|
||||||
engine_factory: Optional factory for creating GameEngine instances.
|
engine_factory: Optional factory for creating GameEngine for action
|
||||||
If not provided, uses the default _default_engine_factory method.
|
execution. Takes GameState, returns GameEngine. If not provided,
|
||||||
Useful for testing with mock engines.
|
uses the default _default_engine_factory method.
|
||||||
|
creation_engine_factory: Optional factory for creating GameEngine for
|
||||||
|
game creation. Takes RulesConfig, returns GameEngine. If not
|
||||||
|
provided, uses the default _default_creation_engine_factory method.
|
||||||
"""
|
"""
|
||||||
self._state_manager = state_manager or game_state_manager
|
self._state_manager = state_manager or game_state_manager
|
||||||
self._card_service = card_service or get_card_service()
|
self._card_service = card_service or get_card_service()
|
||||||
self._engine_factory = engine_factory or self._default_engine_factory
|
self._engine_factory = engine_factory or self._default_engine_factory
|
||||||
|
self._creation_engine_factory = (
|
||||||
|
creation_engine_factory or self._default_creation_engine_factory
|
||||||
|
)
|
||||||
|
|
||||||
def _default_engine_factory(self, game: GameState) -> GameEngine:
|
def _default_engine_factory(self, game: GameState) -> GameEngine:
|
||||||
"""Default factory for creating a GameEngine from game state.
|
"""Default factory for creating a GameEngine from game state.
|
||||||
@ -239,6 +288,20 @@ class GameService:
|
|||||||
|
|
||||||
return GameEngine(rules=game.rules, rng=rng)
|
return GameEngine(rules=game.rules, rng=rng)
|
||||||
|
|
||||||
|
def _default_creation_engine_factory(self, rules: RulesConfig) -> GameEngine:
|
||||||
|
"""Default factory for creating a GameEngine for game creation.
|
||||||
|
|
||||||
|
Used when creating a new game. The engine is created with the
|
||||||
|
provided rules and a fresh RNG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rules: The RulesConfig for the new game.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A GameEngine configured with the rules and fresh RNG.
|
||||||
|
"""
|
||||||
|
return GameEngine(rules=rules, rng=create_rng())
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Game State Access
|
# Game State Access
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@ -520,45 +583,195 @@ class GameService:
|
|||||||
logger.info(f"Game {game_id} forcibly ended: winner={winner_id}, reason={end_reason}")
|
logger.info(f"Game {game_id} forcibly ended: winner={winner_id}, reason={end_reason}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Game Creation (Skeleton - Full implementation in GS-002)
|
# Game Creation
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
def _cards_to_instances(
|
||||||
|
self,
|
||||||
|
cards: list,
|
||||||
|
player_id: str,
|
||||||
|
prefix: str = "card",
|
||||||
|
) -> list[CardInstance]:
|
||||||
|
"""Convert CardDefinitions to CardInstances with unique IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cards: List of CardDefinition objects.
|
||||||
|
player_id: The owning player's ID (used in instance ID).
|
||||||
|
prefix: Prefix for instance IDs (e.g., "main" or "energy").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CardInstance objects with unique instance_ids.
|
||||||
|
"""
|
||||||
|
instances = []
|
||||||
|
for i, card_def in enumerate(cards):
|
||||||
|
instance = CardInstance(
|
||||||
|
instance_id=f"{player_id}-{prefix}-{i}-{uuid_module.uuid4().hex[:8]}",
|
||||||
|
definition_id=card_def.id,
|
||||||
|
)
|
||||||
|
instances.append(instance)
|
||||||
|
return instances
|
||||||
|
|
||||||
|
def _build_card_registry(
|
||||||
|
self,
|
||||||
|
*card_lists: list,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a card registry from card definition lists.
|
||||||
|
|
||||||
|
Only includes cards that are actually in the decks, not all cards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
card_lists: Variable number of CardDefinition lists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping definition_id -> CardDefinition.
|
||||||
|
"""
|
||||||
|
registry: dict[str, Any] = {}
|
||||||
|
for cards in card_lists:
|
||||||
|
for card_def in cards:
|
||||||
|
if card_def.id not in registry:
|
||||||
|
registry[card_def.id] = card_def
|
||||||
|
return registry
|
||||||
|
|
||||||
async def create_game(
|
async def create_game(
|
||||||
self,
|
self,
|
||||||
player1_id: str | UUID,
|
player1_id: UUID,
|
||||||
player2_id: str | UUID,
|
player2_id: UUID,
|
||||||
deck1_id: str | UUID | None = None,
|
deck1_id: UUID,
|
||||||
deck2_id: str | UUID | None = None,
|
deck2_id: UUID,
|
||||||
# Rules come from the frontend request - this is required, not optional
|
rules_config: RulesConfig,
|
||||||
# Defaulting to None here only for the skeleton; GS-002 will make it required
|
deck_service: DeckService,
|
||||||
) -> None:
|
game_type: GameType = GameType.FREEPLAY,
|
||||||
|
game_id: str | None = None,
|
||||||
|
) -> GameCreateResult:
|
||||||
"""Create a new game between two players.
|
"""Create a new game between two players.
|
||||||
|
|
||||||
This is a skeleton that will be fully implemented in GS-002.
|
Loads decks via DeckService, converts cards to instances, initializes
|
||||||
|
game state via GameEngine, and persists to both Redis and Postgres.
|
||||||
|
|
||||||
IMPORTANT: rules_config will be a required parameter - it comes
|
IMPORTANT: rules_config is required and comes from the frontend
|
||||||
from the frontend request, not from server-side defaults.
|
request, not from server-side defaults.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player1_id: First player's ID.
|
player1_id: First player's UUID.
|
||||||
player2_id: Second player's ID.
|
player2_id: Second player's UUID.
|
||||||
deck1_id: First player's deck ID.
|
deck1_id: First player's deck UUID.
|
||||||
deck2_id: Second player's deck ID.
|
deck2_id: Second player's deck UUID.
|
||||||
|
rules_config: Game rules from the frontend request.
|
||||||
|
deck_service: DeckService instance for loading decks (from API layer).
|
||||||
|
game_type: Type of game (freeplay, ranked, etc.). Defaults to FREEPLAY.
|
||||||
|
game_id: Optional game ID. Auto-generated if not provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GameCreateResult with game_id, initial player views, and starting player.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotImplementedError: Until GS-002 is complete.
|
GameCreationError: If deck loading or game creation fails.
|
||||||
"""
|
"""
|
||||||
# TODO (GS-002): Full implementation with:
|
p1_str = str(player1_id)
|
||||||
# - rules_config: RulesConfig parameter (required, from frontend)
|
p2_str = str(player2_id)
|
||||||
# - Load decks via DeckService
|
|
||||||
# - Load card registry from CardService
|
|
||||||
# - Convert to CardInstances with unique IDs
|
|
||||||
# - Create GameState with the provided rules_config
|
|
||||||
# - Persist to Redis and Postgres
|
|
||||||
|
|
||||||
raise NotImplementedError(
|
# Load decks via DeckService
|
||||||
"Game creation not yet implemented - see GS-002. "
|
try:
|
||||||
"Rules will come from frontend request, not server defaults."
|
# get_deck_for_game returns list[CardDefinition] expanded for quantities
|
||||||
|
deck1_cards = await deck_service.get_deck_for_game(player1_id, deck1_id)
|
||||||
|
deck2_cards = await deck_service.get_deck_for_game(player2_id, deck2_id)
|
||||||
|
|
||||||
|
# get_deck returns DeckEntry with energy_cards dict
|
||||||
|
deck1_entry = await deck_service.get_deck(player1_id, deck1_id)
|
||||||
|
deck2_entry = await deck_service.get_deck(player2_id, deck2_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load decks: {e}")
|
||||||
|
raise GameCreationError(f"Failed to load decks: {e}") from e
|
||||||
|
|
||||||
|
# Expand energy cards to CardDefinitions
|
||||||
|
energy1_cards = []
|
||||||
|
for energy_type, qty in deck1_entry.energy_cards.items():
|
||||||
|
# Energy card IDs follow pattern: energy-basic-{type}
|
||||||
|
energy_id = f"energy-basic-{energy_type}"
|
||||||
|
energy_def = self._card_service.get_card(energy_id)
|
||||||
|
if energy_def:
|
||||||
|
energy1_cards.extend([energy_def] * qty)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Energy card not found: {energy_id}")
|
||||||
|
|
||||||
|
energy2_cards = []
|
||||||
|
for energy_type, qty in deck2_entry.energy_cards.items():
|
||||||
|
energy_id = f"energy-basic-{energy_type}"
|
||||||
|
energy_def = self._card_service.get_card(energy_id)
|
||||||
|
if energy_def:
|
||||||
|
energy2_cards.extend([energy_def] * qty)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Energy card not found: {energy_id}")
|
||||||
|
|
||||||
|
# Convert CardDefinitions to CardInstances with unique IDs
|
||||||
|
deck1_instances = self._cards_to_instances(deck1_cards, p1_str, "main")
|
||||||
|
deck2_instances = self._cards_to_instances(deck2_cards, p2_str, "main")
|
||||||
|
energy1_instances = self._cards_to_instances(energy1_cards, p1_str, "energy")
|
||||||
|
energy2_instances = self._cards_to_instances(energy2_cards, p2_str, "energy")
|
||||||
|
|
||||||
|
# Build decks dict for GameEngine
|
||||||
|
decks = {
|
||||||
|
p1_str: deck1_instances,
|
||||||
|
p2_str: deck2_instances,
|
||||||
|
}
|
||||||
|
energy_decks = {
|
||||||
|
p1_str: energy1_instances,
|
||||||
|
p2_str: energy2_instances,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build card registry from only the cards in play
|
||||||
|
card_registry = self._build_card_registry(
|
||||||
|
deck1_cards, deck2_cards, energy1_cards, energy2_cards
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create engine with the provided rules via factory (allows testing)
|
||||||
|
engine = self._creation_engine_factory(rules_config)
|
||||||
|
|
||||||
|
# Create the game via GameEngine
|
||||||
|
result: GameCreationResult = engine.create_game(
|
||||||
|
player_ids=[p1_str, p2_str],
|
||||||
|
decks=decks,
|
||||||
|
card_registry=card_registry,
|
||||||
|
energy_decks=energy_decks if energy1_instances or energy2_instances else None,
|
||||||
|
game_id=game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success or result.game is None:
|
||||||
|
logger.error(f"GameEngine failed to create game: {result.message}")
|
||||||
|
raise GameCreationError(result.message)
|
||||||
|
|
||||||
|
game = result.game
|
||||||
|
|
||||||
|
# Persist to both cache and database
|
||||||
|
try:
|
||||||
|
await self._state_manager.save_to_cache(game)
|
||||||
|
await self._state_manager.persist_to_db(
|
||||||
|
game,
|
||||||
|
game_type=game_type,
|
||||||
|
player1_id=player1_id,
|
||||||
|
player2_id=player2_id,
|
||||||
|
rules_config=rules_config.model_dump() if rules_config else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to persist game state: {e}")
|
||||||
|
raise GameCreationError(f"Failed to persist game: {e}") from e
|
||||||
|
|
||||||
|
# Get player-visible views
|
||||||
|
player1_view = get_visible_state(game, p1_str)
|
||||||
|
player2_view = get_visible_state(game, p2_str)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created game {game.game_id}: {p1_str} vs {p2_str}, "
|
||||||
|
f"starting player: {game.current_player_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return GameCreateResult(
|
||||||
|
success=True,
|
||||||
|
game_id=game.game_id,
|
||||||
|
player1_view=player1_view,
|
||||||
|
player2_view=player2_view,
|
||||||
|
starting_player_id=game.current_player_id,
|
||||||
|
message="Game created successfully",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
"description": "Real-time gameplay infrastructure - WebSocket communication, game lifecycle management, reconnection handling, and turn timeout system",
|
"description": "Real-time gameplay infrastructure - WebSocket communication, game lifecycle management, reconnection handling, and turn timeout system",
|
||||||
"totalEstimatedHours": 45,
|
"totalEstimatedHours": 45,
|
||||||
"totalTasks": 18,
|
"totalTasks": 18,
|
||||||
"completedTasks": 5,
|
"completedTasks": 7,
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
||||||
},
|
},
|
||||||
@ -201,11 +201,11 @@
|
|||||||
"description": "Service layer orchestrating game lifecycle between WebSocket and GameEngine",
|
"description": "Service layer orchestrating game lifecycle between WebSocket and GameEngine",
|
||||||
"category": "services",
|
"category": "services",
|
||||||
"priority": 5,
|
"priority": 5,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["WS-002"],
|
"dependencies": ["WS-002"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "app/services/game_service.py", "status": "create"}
|
{"path": "app/services/game_service.py", "status": "created"}
|
||||||
],
|
],
|
||||||
"details": [
|
"details": [
|
||||||
"Constructor injection: GameEngine, GameStateManager, DeckService, CardService",
|
"Constructor injection: GameEngine, GameStateManager, DeckService, CardService",
|
||||||
@ -224,11 +224,11 @@
|
|||||||
"description": "Create new game from player decks, initialize in Redis and Postgres",
|
"description": "Create new game from player decks, initialize in Redis and Postgres",
|
||||||
"category": "services",
|
"category": "services",
|
||||||
"priority": 6,
|
"priority": 6,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["GS-001"],
|
"dependencies": ["GS-001"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "app/services/game_service.py", "status": "modify"}
|
{"path": "app/services/game_service.py", "status": "modified"}
|
||||||
],
|
],
|
||||||
"details": [
|
"details": [
|
||||||
"Accept player IDs, deck IDs, game type, optional rules config",
|
"Accept player IDs, deck IDs, game type, optional rules config",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from app.core.models.game_state import GameState, PlayerState
|
|||||||
from app.core.win_conditions import WinResult
|
from app.core.win_conditions import WinResult
|
||||||
from app.services.game_service import (
|
from app.services.game_service import (
|
||||||
GameAlreadyEndedError,
|
GameAlreadyEndedError,
|
||||||
|
GameCreationError,
|
||||||
GameNotFoundError,
|
GameNotFoundError,
|
||||||
GameService,
|
GameService,
|
||||||
InvalidActionError,
|
InvalidActionError,
|
||||||
@ -639,25 +640,218 @@ class TestEndGame:
|
|||||||
|
|
||||||
|
|
||||||
class TestCreateGame:
|
class TestCreateGame:
|
||||||
"""Tests for the create_game method (skeleton)."""
|
"""Tests for the create_game method."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_deck_service(self) -> AsyncMock:
|
||||||
|
"""Create a mock DeckService for game creation tests."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.core.enums import CardType, EnergyType, PokemonStage
|
||||||
|
from app.core.models.card import Attack, CardDefinition
|
||||||
|
from app.repositories.protocols import DeckEntry
|
||||||
|
|
||||||
|
# Create sample card definitions - need enough for a valid deck (40 cards)
|
||||||
|
pikachu = CardDefinition(
|
||||||
|
id="pikachu-001",
|
||||||
|
name="Pikachu",
|
||||||
|
card_type=CardType.POKEMON,
|
||||||
|
stage=PokemonStage.BASIC,
|
||||||
|
hp=60,
|
||||||
|
pokemon_type=EnergyType.LIGHTNING,
|
||||||
|
attacks=[Attack(name="Thunder Shock", damage=20)],
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AsyncMock()
|
||||||
|
# get_deck_for_game returns expanded list of CardDefinitions (40 cards)
|
||||||
|
service.get_deck_for_game = AsyncMock(return_value=[pikachu] * 40)
|
||||||
|
|
||||||
|
# get_deck returns DeckEntry with energy_cards (20 energy for energy deck)
|
||||||
|
deck_entry = MagicMock(spec=DeckEntry)
|
||||||
|
deck_entry.energy_cards = {"lightning": 20}
|
||||||
|
service.get_deck = AsyncMock(return_value=deck_entry)
|
||||||
|
|
||||||
|
return service
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_card_service_with_energy(self) -> MagicMock:
|
||||||
|
"""CardService that can resolve energy card IDs."""
|
||||||
|
from app.core.enums import CardType, EnergyType
|
||||||
|
from app.core.models.card import CardDefinition
|
||||||
|
|
||||||
|
energy = CardDefinition(
|
||||||
|
id="energy-basic-lightning",
|
||||||
|
name="Lightning Energy",
|
||||||
|
card_type=CardType.ENERGY,
|
||||||
|
energy_type=EnergyType.LIGHTNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = MagicMock()
|
||||||
|
service.get_card = MagicMock(return_value=energy)
|
||||||
|
service.get_all_cards = MagicMock(return_value={})
|
||||||
|
return service
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_engine_for_create(self) -> MagicMock:
|
||||||
|
"""GameEngine mock that returns a successful GameCreationResult.
|
||||||
|
|
||||||
|
The mock dynamically creates a game with the player IDs that were
|
||||||
|
passed to create_game, ensuring visibility checks pass.
|
||||||
|
"""
|
||||||
|
from app.core.engine import GameCreationResult
|
||||||
|
from app.core.models.game_state import GameState, PlayerState
|
||||||
|
|
||||||
|
def create_game_side_effect(player_ids, decks, card_registry, **kwargs):
|
||||||
|
"""Create a game with the provided player IDs."""
|
||||||
|
p1, p2 = player_ids[0], player_ids[1]
|
||||||
|
game = GameState(
|
||||||
|
game_id=kwargs.get("game_id") or "test-game-123",
|
||||||
|
players={
|
||||||
|
p1: PlayerState(player_id=p1),
|
||||||
|
p2: PlayerState(player_id=p2),
|
||||||
|
},
|
||||||
|
current_player_id=p1,
|
||||||
|
card_registry=card_registry,
|
||||||
|
)
|
||||||
|
return GameCreationResult(success=True, game=game)
|
||||||
|
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.create_game = MagicMock(side_effect=create_game_side_effect)
|
||||||
|
return engine
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_game_raises_not_implemented(
|
async def test_create_game_success(
|
||||||
self,
|
self,
|
||||||
game_service: GameService,
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_card_service_with_energy: MagicMock,
|
||||||
|
mock_engine_for_create: MagicMock,
|
||||||
|
mock_deck_service: AsyncMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that create_game raises NotImplementedError.
|
"""Test successful game creation.
|
||||||
|
|
||||||
The full implementation will be done in GS-002. For now,
|
Verifies that create_game:
|
||||||
it should raise NotImplementedError with a clear message.
|
1. Loads decks via DeckService
|
||||||
|
2. Converts cards to instances
|
||||||
|
3. Calls GameEngine.create_game
|
||||||
|
4. Persists to cache and database
|
||||||
|
5. Returns GameCreateResult with player views
|
||||||
"""
|
"""
|
||||||
with pytest.raises(NotImplementedError) as exc_info:
|
from app.core.config import RulesConfig
|
||||||
await game_service.create_game(
|
from app.services.game_service import GameService
|
||||||
player1_id=str(uuid4()),
|
|
||||||
player2_id=str(uuid4()),
|
service = GameService(
|
||||||
|
state_manager=mock_state_manager,
|
||||||
|
card_service=mock_card_service_with_energy,
|
||||||
|
engine_factory=lambda game: mock_engine_for_create,
|
||||||
|
creation_engine_factory=lambda rules: mock_engine_for_create,
|
||||||
|
)
|
||||||
|
|
||||||
|
player1_id = uuid4()
|
||||||
|
player2_id = uuid4()
|
||||||
|
deck1_id = uuid4()
|
||||||
|
deck2_id = uuid4()
|
||||||
|
|
||||||
|
result = await service.create_game(
|
||||||
|
player1_id=player1_id,
|
||||||
|
player2_id=player2_id,
|
||||||
|
deck1_id=deck1_id,
|
||||||
|
deck2_id=deck2_id,
|
||||||
|
rules_config=RulesConfig(),
|
||||||
|
deck_service=mock_deck_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.game_id == "test-game-123"
|
||||||
|
assert result.starting_player_id is not None
|
||||||
|
assert result.player1_view is not None
|
||||||
|
assert result.player2_view is not None
|
||||||
|
|
||||||
|
# Verify decks were loaded
|
||||||
|
assert mock_deck_service.get_deck_for_game.call_count == 2
|
||||||
|
assert mock_deck_service.get_deck.call_count == 2
|
||||||
|
|
||||||
|
# Verify state was persisted
|
||||||
|
mock_state_manager.save_to_cache.assert_called_once()
|
||||||
|
mock_state_manager.persist_to_db.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_game_deck_load_failure(
|
||||||
|
self,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_card_service: MagicMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that deck loading failure raises GameCreationError.
|
||||||
|
|
||||||
|
If DeckService fails to load a deck (not found, not owned, etc.),
|
||||||
|
create_game should raise a clear error.
|
||||||
|
"""
|
||||||
|
from app.core.config import RulesConfig
|
||||||
|
from app.services.game_service import GameService
|
||||||
|
|
||||||
|
service = GameService(
|
||||||
|
state_manager=mock_state_manager,
|
||||||
|
card_service=mock_card_service,
|
||||||
|
engine_factory=lambda game: mock_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock DeckService that fails
|
||||||
|
mock_deck_service = AsyncMock()
|
||||||
|
mock_deck_service.get_deck_for_game = AsyncMock(side_effect=ValueError("Deck not found"))
|
||||||
|
|
||||||
|
with pytest.raises(GameCreationError) as exc_info:
|
||||||
|
await service.create_game(
|
||||||
|
player1_id=uuid4(),
|
||||||
|
player2_id=uuid4(),
|
||||||
|
deck1_id=uuid4(),
|
||||||
|
deck2_id=uuid4(),
|
||||||
|
rules_config=RulesConfig(),
|
||||||
|
deck_service=mock_deck_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "GS-002" in str(exc_info.value)
|
assert "Failed to load decks" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_game_engine_failure(
|
||||||
|
self,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_card_service_with_energy: MagicMock,
|
||||||
|
mock_deck_service: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that GameEngine failure raises GameCreationError.
|
||||||
|
|
||||||
|
If GameEngine.create_game fails (invalid decks, rule violations),
|
||||||
|
create_game should raise a clear error.
|
||||||
|
"""
|
||||||
|
from app.core.config import RulesConfig
|
||||||
|
from app.core.engine import GameCreationResult
|
||||||
|
from app.services.game_service import GameService
|
||||||
|
|
||||||
|
# Engine that fails to create game
|
||||||
|
mock_engine = MagicMock()
|
||||||
|
mock_engine.create_game = MagicMock(
|
||||||
|
return_value=GameCreationResult(
|
||||||
|
success=False, game=None, message="No basic Pokemon in deck"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service = GameService(
|
||||||
|
state_manager=mock_state_manager,
|
||||||
|
card_service=mock_card_service_with_energy,
|
||||||
|
creation_engine_factory=lambda rules: mock_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(GameCreationError) as exc_info:
|
||||||
|
await service.create_game(
|
||||||
|
player1_id=uuid4(),
|
||||||
|
player2_id=uuid4(),
|
||||||
|
deck1_id=uuid4(),
|
||||||
|
deck2_id=uuid4(),
|
||||||
|
rules_config=RulesConfig(),
|
||||||
|
deck_service=mock_deck_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "No basic Pokemon" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultEngineFactory:
|
class TestDefaultEngineFactory:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user