From 3c75ee0e00c33c88baa30add66636fdfbbcb2c04 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 29 Jan 2026 14:09:11 -0600 Subject: [PATCH] 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 --- backend/app/services/game_service.py | 279 +++++++++++++++--- .../project_plans/PHASE_4_GAME_SERVICE.json | 14 +- .../tests/unit/services/test_game_service.py | 216 +++++++++++++- 3 files changed, 458 insertions(+), 51 deletions(-) diff --git a/backend/app/services/game_service.py b/backend/app/services/game_service.py index 105b8f1..03515da 100644 --- a/backend/app/services/game_service.py +++ b/backend/app/services/game_service.py @@ -35,26 +35,38 @@ Example: ) """ +from __future__ import annotations + import logging +import uuid as uuid_module from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any 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.models.actions import Action, ResignAction +from app.core.models.card import CardInstance from app.core.models.game_state import GameState from app.core.rng import create_rng 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.game_state_manager import GameStateManager, game_state_manager 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] +# Type alias for creation factory - takes RulesConfig, returns GameEngine (for create_game) +CreationEngineFactory = Callable[[RulesConfig], GameEngine] + # ============================================================================= # Exceptions @@ -114,6 +126,14 @@ class GameAlreadyEndedError(GameServiceError): 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 # ============================================================================= @@ -165,6 +185,27 @@ class GameJoinResult: 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 # ============================================================================= @@ -184,7 +225,8 @@ class GameService: Attributes: _state_manager: GameStateManager for persistence. _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__( @@ -192,6 +234,7 @@ class GameService: state_manager: GameStateManager | None = None, card_service: CardService | None = None, engine_factory: EngineFactory | None = None, + creation_engine_factory: CreationEngineFactory | None = None, ) -> None: """Initialize the GameService. @@ -202,13 +245,19 @@ class GameService: Args: state_manager: GameStateManager instance. Uses global if not provided. card_service: CardService instance. Uses global if not provided. - engine_factory: Optional factory for creating GameEngine instances. - If not provided, uses the default _default_engine_factory method. - Useful for testing with mock engines. + engine_factory: Optional factory for creating GameEngine for action + execution. Takes GameState, returns GameEngine. If not provided, + 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._card_service = card_service or get_card_service() 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: """Default factory for creating a GameEngine from game state. @@ -239,6 +288,20 @@ class GameService: 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 # ========================================================================= @@ -520,45 +583,195 @@ class GameService: 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( self, - player1_id: str | UUID, - player2_id: str | UUID, - deck1_id: str | UUID | None = None, - deck2_id: str | UUID | None = None, - # Rules come from the frontend request - this is required, not optional - # Defaulting to None here only for the skeleton; GS-002 will make it required - ) -> None: + player1_id: UUID, + player2_id: UUID, + deck1_id: UUID, + deck2_id: UUID, + rules_config: RulesConfig, + deck_service: DeckService, + game_type: GameType = GameType.FREEPLAY, + game_id: str | None = None, + ) -> GameCreateResult: """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 - from the frontend request, not from server-side defaults. + IMPORTANT: rules_config is required and comes from the frontend + request, not from server-side defaults. Args: - player1_id: First player's ID. - player2_id: Second player's ID. - deck1_id: First player's deck ID. - deck2_id: Second player's deck ID. + player1_id: First player's UUID. + player2_id: Second player's UUID. + deck1_id: First player's deck UUID. + 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: - NotImplementedError: Until GS-002 is complete. + GameCreationError: If deck loading or game creation fails. """ - # TODO (GS-002): Full implementation with: - # - rules_config: RulesConfig parameter (required, from frontend) - # - 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 + p1_str = str(player1_id) + p2_str = str(player2_id) - raise NotImplementedError( - "Game creation not yet implemented - see GS-002. " - "Rules will come from frontend request, not server defaults." + # Load decks via DeckService + try: + # 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", ) diff --git a/backend/project_plans/PHASE_4_GAME_SERVICE.json b/backend/project_plans/PHASE_4_GAME_SERVICE.json index 12cc4cf..a2f2f33 100644 --- a/backend/project_plans/PHASE_4_GAME_SERVICE.json +++ b/backend/project_plans/PHASE_4_GAME_SERVICE.json @@ -9,7 +9,7 @@ "description": "Real-time gameplay infrastructure - WebSocket communication, game lifecycle management, reconnection handling, and turn timeout system", "totalEstimatedHours": 45, "totalTasks": 18, - "completedTasks": 5, + "completedTasks": 7, "status": "in_progress", "masterPlan": "../PROJECT_PLAN_MASTER.json" }, @@ -201,11 +201,11 @@ "description": "Service layer orchestrating game lifecycle between WebSocket and GameEngine", "category": "services", "priority": 5, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["WS-002"], "files": [ - {"path": "app/services/game_service.py", "status": "create"} + {"path": "app/services/game_service.py", "status": "created"} ], "details": [ "Constructor injection: GameEngine, GameStateManager, DeckService, CardService", @@ -224,11 +224,11 @@ "description": "Create new game from player decks, initialize in Redis and Postgres", "category": "services", "priority": 6, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["GS-001"], "files": [ - {"path": "app/services/game_service.py", "status": "modify"} + {"path": "app/services/game_service.py", "status": "modified"} ], "details": [ "Accept player IDs, deck IDs, game type, optional rules config", diff --git a/backend/tests/unit/services/test_game_service.py b/backend/tests/unit/services/test_game_service.py index f7aa19d..1cc891d 100644 --- a/backend/tests/unit/services/test_game_service.py +++ b/backend/tests/unit/services/test_game_service.py @@ -23,6 +23,7 @@ from app.core.models.game_state import GameState, PlayerState from app.core.win_conditions import WinResult from app.services.game_service import ( GameAlreadyEndedError, + GameCreationError, GameNotFoundError, GameService, InvalidActionError, @@ -639,25 +640,218 @@ class TestEndGame: 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 - async def test_create_game_raises_not_implemented( + async def test_create_game_success( self, - game_service: GameService, + mock_state_manager: AsyncMock, + mock_card_service_with_energy: MagicMock, + mock_engine_for_create: MagicMock, + mock_deck_service: AsyncMock, ) -> None: - """Test that create_game raises NotImplementedError. + """Test successful game creation. - The full implementation will be done in GS-002. For now, - it should raise NotImplementedError with a clear message. + Verifies that create_game: + 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: - await game_service.create_game( - player1_id=str(uuid4()), - player2_id=str(uuid4()), + 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_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: