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

View File

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

View File

@ -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.mark.asyncio
async def test_create_game_raises_not_implemented(
self,
game_service: GameService,
) -> None:
"""Test that create_game raises NotImplementedError.
@pytest.fixture
def mock_deck_service(self) -> AsyncMock:
"""Create a mock DeckService for game creation tests."""
from unittest.mock import MagicMock
The full implementation will be done in GS-002. For now,
it should raise NotImplementedError with a clear message.
"""
with pytest.raises(NotImplementedError) as exc_info:
await game_service.create_game(
player1_id=str(uuid4()),
player2_id=str(uuid4()),
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)],
)
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: