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:
Cal Corum 2026-01-29 14:09:11 -06:00
parent f93f5b617a
commit 3c75ee0e00
3 changed files with 458 additions and 51 deletions

View File

@ -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",
) )

View File

@ -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",

View File

@ -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.mark.asyncio @pytest.fixture
async def test_create_game_raises_not_implemented( def mock_deck_service(self) -> AsyncMock:
self, """Create a mock DeckService for game creation tests."""
game_service: GameService, from unittest.mock import MagicMock
) -> None:
"""Test that create_game raises NotImplementedError.
The full implementation will be done in GS-002. For now, from app.core.enums import CardType, EnergyType, PokemonStage
it should raise NotImplementedError with a clear message. from app.core.models.card import Attack, CardDefinition
""" from app.repositories.protocols import DeckEntry
with pytest.raises(NotImplementedError) as exc_info:
await game_service.create_game( # Create sample card definitions - need enough for a valid deck (40 cards)
player1_id=str(uuid4()), pikachu = CardDefinition(
player2_id=str(uuid4()), 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)],
) )
assert "GS-002" in str(exc_info.value) 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_success(
self,
mock_state_manager: AsyncMock,
mock_card_service_with_energy: MagicMock,
mock_engine_for_create: MagicMock,
mock_deck_service: AsyncMock,
) -> None:
"""Test successful game creation.
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
"""
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 "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: