Implement GameService.end_game with history archival (GS-005)
- Add GameEndResult dataclass with winner, loser, final views, duration - Add _map_end_reason() to map core GameEndReason to DB EndReason (raises ValueError for unknown reasons to catch missing enum sync) - Enhance end_game() to build replay data and return comprehensive result - Add archive_to_history() to GameStateManager for complete game archival: - Creates GameHistory record with replay data - Deletes ActiveGame record - Clears Redis cache - All in single transaction - Add ArchiveResult dataclass for archive operation metadata - Add TODO for session_factory DI refactor in GameStateManager - Update tests: 5 new end_game tests, 6 new archive_to_history tests Phase 4 progress: 10/18 tasks complete Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d5460ff418
commit
531d3e1e79
@ -55,7 +55,7 @@ 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.db.models.game import EndReason, GameType
|
||||
from app.services.card_service import CardService, get_card_service
|
||||
from app.services.game_state_manager import GameStateManager, game_state_manager
|
||||
|
||||
@ -68,6 +68,41 @@ EngineFactory = Callable[[GameState], GameEngine]
|
||||
CreationEngineFactory = Callable[[RulesConfig], GameEngine]
|
||||
|
||||
|
||||
def _map_end_reason(core_reason: GameEndReason) -> EndReason:
|
||||
"""Map core GameEndReason to database EndReason.
|
||||
|
||||
The core module uses its own enum for offline independence,
|
||||
while the database has a separate enum for persistence. The DB enum
|
||||
also includes service-layer reasons (like DISCONNECTION) that the
|
||||
core engine doesn't know about.
|
||||
|
||||
Args:
|
||||
core_reason: The GameEndReason from the core module.
|
||||
|
||||
Returns:
|
||||
The corresponding EndReason for database storage.
|
||||
|
||||
Raises:
|
||||
ValueError: If core_reason is not in the mapping (indicates missing enum sync).
|
||||
"""
|
||||
mapping = {
|
||||
GameEndReason.PRIZES_TAKEN: EndReason.PRIZES_TAKEN,
|
||||
GameEndReason.NO_POKEMON: EndReason.NO_POKEMON,
|
||||
GameEndReason.DECK_EMPTY: EndReason.CANNOT_DRAW,
|
||||
GameEndReason.RESIGNATION: EndReason.RESIGNATION,
|
||||
GameEndReason.TIMEOUT: EndReason.TIMEOUT,
|
||||
GameEndReason.TURN_LIMIT: EndReason.TURN_LIMIT,
|
||||
GameEndReason.DRAW: EndReason.DRAW,
|
||||
}
|
||||
result = mapping.get(core_reason)
|
||||
if result is None:
|
||||
raise ValueError(
|
||||
f"Unknown GameEndReason: {core_reason}. "
|
||||
"Update _map_end_reason when adding new end reasons to core."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exceptions
|
||||
# =============================================================================
|
||||
@ -256,6 +291,40 @@ class GameCreateResult:
|
||||
message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameEndResult:
|
||||
"""Result of ending a game.
|
||||
|
||||
Contains all information needed to notify clients and record
|
||||
the game in history.
|
||||
|
||||
Attributes:
|
||||
success: Whether the game was ended successfully.
|
||||
game_id: The game that ended.
|
||||
winner_id: The winner's player ID (None for draws).
|
||||
loser_id: The loser's player ID (None for draws).
|
||||
end_reason: Why the game ended.
|
||||
turn_count: Total number of turns played.
|
||||
duration_seconds: Game duration in seconds.
|
||||
player1_final_view: Final state visible to player 1.
|
||||
player2_final_view: Final state visible to player 2.
|
||||
history_id: The GameHistory record ID.
|
||||
message: Description or error message.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
game_id: str
|
||||
winner_id: str | None = None
|
||||
loser_id: str | None = None
|
||||
end_reason: GameEndReason | None = None
|
||||
turn_count: int = 0
|
||||
duration_seconds: int = 0
|
||||
player1_final_view: VisibleGameState | None = None
|
||||
player2_final_view: VisibleGameState | None = None
|
||||
history_id: str | None = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GameService
|
||||
# =============================================================================
|
||||
@ -670,31 +739,103 @@ class GameService:
|
||||
game_id: str,
|
||||
winner_id: str | None,
|
||||
end_reason: GameEndReason,
|
||||
) -> None:
|
||||
"""Forcibly end a game (e.g., due to timeout or disconnection).
|
||||
) -> GameEndResult:
|
||||
"""End a game and record it in history.
|
||||
|
||||
This should be called by the timeout system or when a player
|
||||
disconnects without reconnecting within the grace period.
|
||||
This method handles the complete game ending process:
|
||||
1. Updates game state with winner and end reason
|
||||
2. Creates a GameHistory record with replay data
|
||||
3. Deletes the game from ActiveGame table and Redis cache
|
||||
4. Returns final state for client notification
|
||||
|
||||
Called by:
|
||||
- execute_action when a win condition is detected
|
||||
- Timeout system when a player times out
|
||||
- Disconnect handler when a player abandons
|
||||
|
||||
Args:
|
||||
game_id: The game ID.
|
||||
winner_id: The winner's player ID, or None for a draw.
|
||||
end_reason: Why the game ended.
|
||||
|
||||
Returns:
|
||||
GameEndResult with final state and history record ID.
|
||||
|
||||
Raises:
|
||||
GameNotFoundError: If game doesn't exist.
|
||||
"""
|
||||
state = await self.get_game_state(game_id)
|
||||
|
||||
# Set winner and end reason
|
||||
# Set winner and end reason on game state
|
||||
state.winner_id = winner_id
|
||||
state.end_reason = end_reason
|
||||
|
||||
# Persist to both cache and DB
|
||||
await self._state_manager.save_to_cache(state)
|
||||
await self._state_manager.persist_to_db(state)
|
||||
# Get player IDs from state
|
||||
player_ids = list(state.players.keys())
|
||||
player1_id = player_ids[0] if len(player_ids) > 0 else None
|
||||
player2_id = player_ids[1] if len(player_ids) > 1 else None
|
||||
|
||||
logger.info(f"Game {game_id} forcibly ended: winner={winner_id}, reason={end_reason}")
|
||||
# Determine loser (opposite of winner)
|
||||
loser_id: str | None = None
|
||||
if winner_id is not None and len(player_ids) == 2:
|
||||
loser_id = player_ids[1] if player_ids[0] == winner_id else player_ids[0]
|
||||
|
||||
# Get player views for final state
|
||||
player1_view = get_visible_state(state, player1_id) if player1_id else None
|
||||
player2_view = get_visible_state(state, player2_id) if player2_id else None
|
||||
|
||||
# Map core end reason to DB end reason
|
||||
db_end_reason = _map_end_reason(end_reason)
|
||||
|
||||
# Determine if NPC won (for campaign games)
|
||||
winner_is_npc = False
|
||||
db_winner_id: UUID | None = None
|
||||
if winner_id is not None:
|
||||
# Check if winner is a player UUID or NPC
|
||||
try:
|
||||
db_winner_id = UUID(winner_id)
|
||||
except ValueError:
|
||||
# Winner ID is not a UUID, must be NPC
|
||||
winner_is_npc = True
|
||||
db_winner_id = None
|
||||
|
||||
# Build replay data from action log
|
||||
replay_data = {
|
||||
"version": 1,
|
||||
"game_id": game_id,
|
||||
"action_log": state.action_log,
|
||||
"final_turn": state.turn_number,
|
||||
"rules": state.rules.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
# Archive to history (creates GameHistory, deletes ActiveGame and cache)
|
||||
archive_result = await self._state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
winner_id=db_winner_id,
|
||||
winner_is_npc=winner_is_npc,
|
||||
end_reason=db_end_reason,
|
||||
replay_data=replay_data,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Game {game_id} ended: winner={winner_id}, reason={end_reason}, "
|
||||
f"turns={archive_result.turn_count}, duration={archive_result.duration_seconds}s"
|
||||
)
|
||||
|
||||
return GameEndResult(
|
||||
success=True,
|
||||
game_id=game_id,
|
||||
winner_id=winner_id,
|
||||
loser_id=loser_id,
|
||||
end_reason=end_reason,
|
||||
turn_count=archive_result.turn_count,
|
||||
duration_seconds=archive_result.duration_seconds,
|
||||
player1_final_view=player1_view,
|
||||
player2_final_view=player2_view,
|
||||
history_id=archive_result.history_id,
|
||||
message=f"Game ended: {end_reason.value}",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Game Creation
|
||||
|
||||
@ -52,7 +52,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.core.models.game_state import GameState
|
||||
from app.db.models import ActiveGame, GameType
|
||||
from app.db.models import ActiveGame, EndReason, GameHistory, GameType
|
||||
from app.db.redis import RedisHelper, redis_helper
|
||||
from app.db.session import get_session
|
||||
|
||||
@ -74,6 +74,27 @@ class RecoveryResult:
|
||||
total: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchiveResult:
|
||||
"""Result of archiving a game to history.
|
||||
|
||||
Attributes:
|
||||
success: Whether the archive operation succeeded.
|
||||
game_id: The archived game's ID.
|
||||
history_id: The GameHistory record ID (same as game_id).
|
||||
duration_seconds: Game duration in seconds.
|
||||
turn_count: Total turns played.
|
||||
game_type: Type of game that was archived.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
game_id: str
|
||||
history_id: str
|
||||
duration_seconds: int
|
||||
turn_count: int
|
||||
game_type: GameType
|
||||
|
||||
|
||||
# Redis key patterns
|
||||
GAME_KEY_PREFIX = "game:"
|
||||
|
||||
@ -87,6 +108,13 @@ class GameStateManager:
|
||||
|
||||
Attributes:
|
||||
redis: RedisHelper instance for cache operations.
|
||||
|
||||
TODO: Add session_factory parameter for full DI support, similar to
|
||||
ConnectionManager's redis_factory pattern. Currently methods accept
|
||||
optional session parameter, but fallback to get_session() global.
|
||||
This would eliminate need for patching in unit tests. See:
|
||||
- ConnectionManager for reference implementation
|
||||
- tests/unit/services/test_game_state_manager.py for affected tests
|
||||
"""
|
||||
|
||||
def __init__(self, redis: RedisHelper | None = None) -> None:
|
||||
@ -390,6 +418,123 @@ class GameStateManager:
|
||||
|
||||
return deleted
|
||||
|
||||
async def archive_to_history(
|
||||
self,
|
||||
game_id: str,
|
||||
state: GameState,
|
||||
winner_id: UUID | None,
|
||||
winner_is_npc: bool,
|
||||
end_reason: EndReason,
|
||||
replay_data: dict | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> ArchiveResult:
|
||||
"""Archive a completed game to history and cleanup active game.
|
||||
|
||||
This method handles the complete end-of-game persistence:
|
||||
1. Loads ActiveGame to get metadata (started_at, player IDs, game type)
|
||||
2. Creates GameHistory record with replay data
|
||||
3. Deletes ActiveGame record
|
||||
4. Deletes from Redis cache
|
||||
|
||||
All database operations happen in a single transaction.
|
||||
|
||||
Args:
|
||||
game_id: Unique game identifier.
|
||||
state: Final GameState to archive.
|
||||
winner_id: UUID of the winner (None for draws or NPC wins).
|
||||
winner_is_npc: True if NPC won (campaign games).
|
||||
end_reason: Database EndReason enum value.
|
||||
replay_data: Optional replay data dict (action log, rules, etc.).
|
||||
session: Optional existing session for transaction control.
|
||||
|
||||
Returns:
|
||||
ArchiveResult with archive metadata.
|
||||
|
||||
Example:
|
||||
result = await manager.archive_to_history(
|
||||
game_id=game.game_id,
|
||||
state=game,
|
||||
winner_id=UUID(winner_id) if winner_id else None,
|
||||
winner_is_npc=False,
|
||||
end_reason=EndReason.PRIZES_TAKEN,
|
||||
replay_data={"action_log": game.action_log},
|
||||
)
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async def _archive(db: AsyncSession) -> ArchiveResult:
|
||||
# Load ActiveGame to get metadata
|
||||
result = await db.execute(select(ActiveGame).where(ActiveGame.id == UUID(game_id)))
|
||||
active_game = result.scalar_one_or_none()
|
||||
|
||||
# Calculate duration and get metadata
|
||||
if active_game is not None:
|
||||
started_at = active_game.started_at
|
||||
duration_seconds = int((now - started_at).total_seconds())
|
||||
game_type = active_game.game_type
|
||||
db_player1_id = active_game.player1_id
|
||||
db_player2_id = active_game.player2_id
|
||||
npc_id = active_game.npc_id
|
||||
else:
|
||||
# Fallback if ActiveGame not found (shouldn't happen normally)
|
||||
# Player IDs in GameState are strings that may not be valid UUIDs
|
||||
duration_seconds = 0
|
||||
game_type = GameType.FREEPLAY
|
||||
db_player1_id = None
|
||||
db_player2_id = None
|
||||
npc_id = None
|
||||
logger.warning(f"ActiveGame not found for {game_id}, using defaults")
|
||||
|
||||
# Create GameHistory record
|
||||
history = GameHistory(
|
||||
id=UUID(game_id),
|
||||
game_type=game_type,
|
||||
player1_id=db_player1_id,
|
||||
player2_id=db_player2_id,
|
||||
npc_id=npc_id,
|
||||
winner_id=winner_id,
|
||||
winner_is_npc=winner_is_npc,
|
||||
end_reason=end_reason,
|
||||
turn_count=state.turn_number,
|
||||
duration_seconds=duration_seconds,
|
||||
replay_data=replay_data,
|
||||
played_at=now,
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
# Delete ActiveGame record
|
||||
if active_game is not None:
|
||||
await db.delete(active_game)
|
||||
logger.debug(f"Deleted ActiveGame record: {game_id}")
|
||||
|
||||
await db.flush()
|
||||
|
||||
return ArchiveResult(
|
||||
success=True,
|
||||
game_id=game_id,
|
||||
history_id=game_id,
|
||||
duration_seconds=duration_seconds,
|
||||
turn_count=state.turn_number,
|
||||
game_type=game_type,
|
||||
)
|
||||
|
||||
if session:
|
||||
archive_result = await _archive(session)
|
||||
else:
|
||||
async with get_session() as db:
|
||||
archive_result = await _archive(db)
|
||||
await db.commit()
|
||||
|
||||
# Delete from Redis cache (outside transaction)
|
||||
await self.delete_from_cache(game_id)
|
||||
|
||||
logger.info(
|
||||
f"Archived game {game_id} to history: "
|
||||
f"turns={archive_result.turn_count}, duration={archive_result.duration_seconds}s"
|
||||
)
|
||||
|
||||
return archive_result
|
||||
|
||||
async def recover_active_games(
|
||||
self,
|
||||
session: AsyncSession | None = None,
|
||||
|
||||
@ -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": 9,
|
||||
"completedTasks": 10,
|
||||
"status": "in_progress",
|
||||
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
||||
},
|
||||
@ -297,8 +297,8 @@
|
||||
"description": "Handle game completion - record history, cleanup active game",
|
||||
"category": "services",
|
||||
"priority": 9,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["GS-003"],
|
||||
"files": [
|
||||
{"path": "app/services/game_service.py", "status": "modify"}
|
||||
|
||||
@ -40,11 +40,24 @@ def mock_state_manager() -> AsyncMock:
|
||||
The state manager handles persistence to Redis (cache) and
|
||||
Postgres (durable storage).
|
||||
"""
|
||||
from app.db.models.game import GameType
|
||||
from app.services.game_state_manager import ArchiveResult
|
||||
|
||||
manager = AsyncMock()
|
||||
manager.load_state = AsyncMock(return_value=None)
|
||||
manager.save_to_cache = AsyncMock()
|
||||
manager.persist_to_db = AsyncMock()
|
||||
manager.cache_exists = AsyncMock(return_value=False)
|
||||
manager.archive_to_history = AsyncMock(
|
||||
return_value=ArchiveResult(
|
||||
success=True,
|
||||
game_id="game-123",
|
||||
history_id="game-123",
|
||||
duration_seconds=300,
|
||||
turn_count=10,
|
||||
game_type=GameType.FREEPLAY,
|
||||
)
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
@ -998,23 +1011,29 @@ class TestResignGame:
|
||||
|
||||
|
||||
class TestEndGame:
|
||||
"""Tests for the end_game method (forced ending)."""
|
||||
"""Tests for the end_game method.
|
||||
|
||||
The end_game method handles complete game ending:
|
||||
- Sets winner and end reason on game state
|
||||
- Archives game to history via state manager
|
||||
- Returns GameEndResult with final state
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_game_sets_winner_and_reason(
|
||||
async def test_end_game_returns_result_with_winner(
|
||||
self,
|
||||
game_service: GameService,
|
||||
mock_state_manager: AsyncMock,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that end_game sets winner and end reason.
|
||||
"""Test that end_game returns GameEndResult with winner info.
|
||||
|
||||
Used for timeout or disconnection scenarios where the game
|
||||
needs to be forcibly ended.
|
||||
The result should include winner, loser, end reason, and
|
||||
game statistics from the archive operation.
|
||||
"""
|
||||
mock_state_manager.load_state.return_value = sample_game_state
|
||||
|
||||
await game_service.end_game(
|
||||
result = await game_service.end_game(
|
||||
"game-123",
|
||||
winner_id="player-1",
|
||||
end_reason=GameEndReason.TIMEOUT,
|
||||
@ -1024,9 +1043,44 @@ class TestEndGame:
|
||||
assert sample_game_state.winner_id == "player-1"
|
||||
assert sample_game_state.end_reason == GameEndReason.TIMEOUT
|
||||
|
||||
# Verify persistence
|
||||
mock_state_manager.save_to_cache.assert_called_once()
|
||||
mock_state_manager.persist_to_db.assert_called_once()
|
||||
# Verify result
|
||||
assert result.success is True
|
||||
assert result.game_id == "game-123"
|
||||
assert result.winner_id == "player-1"
|
||||
assert result.loser_id == "player-2"
|
||||
assert result.end_reason == GameEndReason.TIMEOUT
|
||||
assert result.duration_seconds == 300
|
||||
assert result.turn_count == 10
|
||||
assert result.history_id == "game-123"
|
||||
|
||||
# Verify archive was called
|
||||
mock_state_manager.archive_to_history.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_game_includes_final_player_views(
|
||||
self,
|
||||
game_service: GameService,
|
||||
mock_state_manager: AsyncMock,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that end_game result includes visibility-filtered final states.
|
||||
|
||||
Each player should receive their own view of the final game state
|
||||
for displaying the game-over screen.
|
||||
"""
|
||||
mock_state_manager.load_state.return_value = sample_game_state
|
||||
|
||||
result = await game_service.end_game(
|
||||
"game-123",
|
||||
winner_id="player-1",
|
||||
end_reason=GameEndReason.PRIZES_TAKEN,
|
||||
)
|
||||
|
||||
# Both players should have final views
|
||||
assert result.player1_final_view is not None
|
||||
assert result.player2_final_view is not None
|
||||
assert result.player1_final_view.viewer_id == "player-1"
|
||||
assert result.player2_final_view.viewer_id == "player-2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_game_draw(
|
||||
@ -1038,10 +1092,11 @@ class TestEndGame:
|
||||
"""Test ending a game as a draw (no winner).
|
||||
|
||||
Some scenarios (timeout with equal scores) can result in a draw.
|
||||
In this case, winner_id and loser_id should both be None.
|
||||
"""
|
||||
mock_state_manager.load_state.return_value = sample_game_state
|
||||
|
||||
await game_service.end_game(
|
||||
result = await game_service.end_game(
|
||||
"game-123",
|
||||
winner_id=None,
|
||||
end_reason=GameEndReason.DRAW,
|
||||
@ -1049,6 +1104,9 @@ class TestEndGame:
|
||||
|
||||
assert sample_game_state.winner_id is None
|
||||
assert sample_game_state.end_reason == GameEndReason.DRAW
|
||||
assert result.winner_id is None
|
||||
assert result.loser_id is None
|
||||
assert result.end_reason == GameEndReason.DRAW
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_game_not_found(
|
||||
@ -1066,6 +1124,37 @@ class TestEndGame:
|
||||
end_reason=GameEndReason.TIMEOUT,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_game_archives_with_replay_data(
|
||||
self,
|
||||
game_service: GameService,
|
||||
mock_state_manager: AsyncMock,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that end_game passes replay data to archive_to_history.
|
||||
|
||||
Replay data includes the action log, final turn number, and
|
||||
rules configuration for future replay functionality.
|
||||
"""
|
||||
from app.db.models.game import EndReason
|
||||
|
||||
sample_game_state.action_log = [{"type": "attack", "index": 0}]
|
||||
mock_state_manager.load_state.return_value = sample_game_state
|
||||
|
||||
await game_service.end_game(
|
||||
"game-123",
|
||||
winner_id="player-1",
|
||||
end_reason=GameEndReason.PRIZES_TAKEN,
|
||||
)
|
||||
|
||||
# Verify archive was called with replay data
|
||||
call_kwargs = mock_state_manager.archive_to_history.call_args.kwargs
|
||||
assert call_kwargs["game_id"] == "game-123"
|
||||
assert call_kwargs["end_reason"] == EndReason.PRIZES_TAKEN
|
||||
assert call_kwargs["replay_data"] is not None
|
||||
assert "action_log" in call_kwargs["replay_data"]
|
||||
assert call_kwargs["replay_data"]["action_log"] == [{"type": "attack", "index": 0}]
|
||||
|
||||
|
||||
class TestCreateGame:
|
||||
"""Tests for the create_game method."""
|
||||
|
||||
371
backend/tests/unit/services/test_game_state_manager.py
Normal file
371
backend/tests/unit/services/test_game_state_manager.py
Normal file
@ -0,0 +1,371 @@
|
||||
"""Tests for GameStateManager.
|
||||
|
||||
This module tests the game state persistence layer that manages
|
||||
the Redis-primary, Postgres-backup storage strategy.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.enums import TurnPhase
|
||||
from app.core.models.game_state import GameState, PlayerState
|
||||
from app.db.models.game import EndReason, GameType
|
||||
from app.services.game_state_manager import ArchiveResult, GameStateManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis() -> AsyncMock:
|
||||
"""Create a mock RedisHelper.
|
||||
|
||||
The RedisHelper handles cache operations for game state.
|
||||
"""
|
||||
redis = AsyncMock()
|
||||
redis.set_json = AsyncMock()
|
||||
redis.get_json = AsyncMock(return_value=None)
|
||||
redis.delete = AsyncMock(return_value=True)
|
||||
redis.exists = AsyncMock(return_value=False)
|
||||
return redis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game_state_manager(mock_redis: AsyncMock) -> GameStateManager:
|
||||
"""Create a GameStateManager with mock Redis."""
|
||||
return GameStateManager(redis=mock_redis)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_game_state() -> GameState:
|
||||
"""Create a sample game state for testing."""
|
||||
player1 = PlayerState(player_id="player-1")
|
||||
player2 = PlayerState(player_id="player-2")
|
||||
|
||||
return GameState(
|
||||
game_id="game-123",
|
||||
players={"player-1": player1, "player-2": player2},
|
||||
current_player_id="player-1",
|
||||
turn_number=10,
|
||||
phase=TurnPhase.MAIN,
|
||||
action_log=[{"type": "attack", "index": 0}],
|
||||
)
|
||||
|
||||
|
||||
class TestArchiveToHistory:
|
||||
"""Tests for the archive_to_history method.
|
||||
|
||||
This method handles the complete game ending process:
|
||||
- Creates GameHistory record
|
||||
- Deletes ActiveGame record
|
||||
- Clears Redis cache
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_to_history_returns_result(
|
||||
self,
|
||||
game_state_manager: GameStateManager,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that archive_to_history returns ArchiveResult.
|
||||
|
||||
The result should contain game metadata and success status.
|
||||
"""
|
||||
from app.db.models.game import ActiveGame
|
||||
|
||||
game_id = str(uuid4())
|
||||
sample_game_state.game_id = game_id
|
||||
player1_id = uuid4()
|
||||
player2_id = uuid4()
|
||||
|
||||
# Create mock ActiveGame
|
||||
mock_active_game = MagicMock(spec=ActiveGame)
|
||||
mock_active_game.id = UUID(game_id)
|
||||
mock_active_game.game_type = GameType.FREEPLAY
|
||||
mock_active_game.player1_id = player1_id
|
||||
mock_active_game.player2_id = player2_id
|
||||
mock_active_game.npc_id = None
|
||||
mock_active_game.started_at = datetime.now(UTC)
|
||||
|
||||
# Mock database session
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_active_game
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.delete = AsyncMock()
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
||||
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
result = await game_state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=sample_game_state,
|
||||
winner_id=player1_id,
|
||||
winner_is_npc=False,
|
||||
end_reason=EndReason.PRIZES_TAKEN,
|
||||
replay_data={"action_log": sample_game_state.action_log},
|
||||
)
|
||||
|
||||
assert isinstance(result, ArchiveResult)
|
||||
assert result.success is True
|
||||
assert result.game_id == game_id
|
||||
assert result.history_id == game_id
|
||||
assert result.turn_count == 10
|
||||
assert result.game_type == GameType.FREEPLAY
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_to_history_creates_game_history(
|
||||
self,
|
||||
game_state_manager: GameStateManager,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that archive_to_history creates a GameHistory record.
|
||||
|
||||
The GameHistory record should be added to the session with
|
||||
all the required fields populated.
|
||||
"""
|
||||
from app.db.models.game import ActiveGame, GameHistory
|
||||
|
||||
game_id = str(uuid4())
|
||||
sample_game_state.game_id = game_id
|
||||
player1_id = uuid4()
|
||||
|
||||
# Create mock ActiveGame
|
||||
mock_active_game = MagicMock(spec=ActiveGame)
|
||||
mock_active_game.id = UUID(game_id)
|
||||
mock_active_game.game_type = GameType.CAMPAIGN
|
||||
mock_active_game.player1_id = player1_id
|
||||
mock_active_game.player2_id = None
|
||||
mock_active_game.npc_id = "grass_trainer_1"
|
||||
mock_active_game.started_at = datetime.now(UTC)
|
||||
|
||||
# Mock database session
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_active_game
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.delete = AsyncMock()
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
||||
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
await game_state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=sample_game_state,
|
||||
winner_id=player1_id,
|
||||
winner_is_npc=False,
|
||||
end_reason=EndReason.RESIGNATION,
|
||||
replay_data={"action_log": []},
|
||||
)
|
||||
|
||||
# Verify GameHistory was added
|
||||
mock_session.add.assert_called_once()
|
||||
added_obj = mock_session.add.call_args[0][0]
|
||||
assert isinstance(added_obj, GameHistory)
|
||||
assert added_obj.id == UUID(game_id)
|
||||
assert added_obj.game_type == GameType.CAMPAIGN
|
||||
assert added_obj.npc_id == "grass_trainer_1"
|
||||
assert added_obj.end_reason == EndReason.RESIGNATION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_to_history_deletes_active_game(
|
||||
self,
|
||||
game_state_manager: GameStateManager,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that archive_to_history deletes the ActiveGame record.
|
||||
|
||||
After archiving to history, the game should be removed from
|
||||
the active games table.
|
||||
"""
|
||||
from app.db.models.game import ActiveGame
|
||||
|
||||
game_id = str(uuid4())
|
||||
sample_game_state.game_id = game_id
|
||||
|
||||
# Create mock ActiveGame
|
||||
mock_active_game = MagicMock(spec=ActiveGame)
|
||||
mock_active_game.id = UUID(game_id)
|
||||
mock_active_game.game_type = GameType.FREEPLAY
|
||||
mock_active_game.player1_id = uuid4()
|
||||
mock_active_game.player2_id = uuid4()
|
||||
mock_active_game.npc_id = None
|
||||
mock_active_game.started_at = datetime.now(UTC)
|
||||
|
||||
# Mock database session
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_active_game
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.delete = AsyncMock()
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
||||
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
await game_state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=sample_game_state,
|
||||
winner_id=uuid4(),
|
||||
winner_is_npc=False,
|
||||
end_reason=EndReason.PRIZES_TAKEN,
|
||||
)
|
||||
|
||||
# Verify ActiveGame was deleted
|
||||
mock_session.delete.assert_called_once_with(mock_active_game)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_to_history_deletes_from_cache(
|
||||
self,
|
||||
game_state_manager: GameStateManager,
|
||||
mock_redis: AsyncMock,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that archive_to_history deletes from Redis cache.
|
||||
|
||||
After archiving, the game state should be removed from the
|
||||
Redis cache to free up memory.
|
||||
"""
|
||||
from app.db.models.game import ActiveGame
|
||||
|
||||
game_id = str(uuid4())
|
||||
sample_game_state.game_id = game_id
|
||||
|
||||
# Create mock ActiveGame
|
||||
mock_active_game = MagicMock(spec=ActiveGame)
|
||||
mock_active_game.id = UUID(game_id)
|
||||
mock_active_game.game_type = GameType.FREEPLAY
|
||||
mock_active_game.player1_id = uuid4()
|
||||
mock_active_game.player2_id = uuid4()
|
||||
mock_active_game.npc_id = None
|
||||
mock_active_game.started_at = datetime.now(UTC)
|
||||
|
||||
# Mock database session
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_active_game
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.delete = AsyncMock()
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
||||
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
await game_state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=sample_game_state,
|
||||
winner_id=uuid4(),
|
||||
winner_is_npc=False,
|
||||
end_reason=EndReason.TIMEOUT,
|
||||
)
|
||||
|
||||
# Verify Redis cache was deleted
|
||||
mock_redis.delete.assert_called_once_with(f"game:{game_id}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_to_history_handles_missing_active_game(
|
||||
self,
|
||||
game_state_manager: GameStateManager,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test that archive handles case when ActiveGame is not found.
|
||||
|
||||
This shouldn't normally happen, but the method should handle
|
||||
it gracefully by using defaults from the game state.
|
||||
"""
|
||||
game_id = str(uuid4())
|
||||
sample_game_state.game_id = game_id
|
||||
|
||||
# Mock database session - ActiveGame not found
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None # Not found
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
||||
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
result = await game_state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=sample_game_state,
|
||||
winner_id=None,
|
||||
winner_is_npc=False,
|
||||
end_reason=EndReason.DRAW,
|
||||
)
|
||||
|
||||
# Should still succeed with defaults
|
||||
assert result.success is True
|
||||
assert result.game_type == GameType.FREEPLAY # Default
|
||||
assert result.duration_seconds == 0 # Default when no started_at
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_to_history_npc_winner(
|
||||
self,
|
||||
game_state_manager: GameStateManager,
|
||||
sample_game_state: GameState,
|
||||
) -> None:
|
||||
"""Test archiving when NPC is the winner.
|
||||
|
||||
In campaign mode, the NPC can win. The winner_is_npc flag
|
||||
should be set correctly in GameHistory.
|
||||
"""
|
||||
from app.db.models.game import ActiveGame, GameHistory
|
||||
|
||||
game_id = str(uuid4())
|
||||
sample_game_state.game_id = game_id
|
||||
player1_id = uuid4()
|
||||
|
||||
# Create mock ActiveGame for campaign
|
||||
mock_active_game = MagicMock(spec=ActiveGame)
|
||||
mock_active_game.id = UUID(game_id)
|
||||
mock_active_game.game_type = GameType.CAMPAIGN
|
||||
mock_active_game.player1_id = player1_id
|
||||
mock_active_game.player2_id = None
|
||||
mock_active_game.npc_id = "fire_leader"
|
||||
mock_active_game.started_at = datetime.now(UTC)
|
||||
|
||||
# Mock database session
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_active_game
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.delete = AsyncMock()
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
|
||||
with patch("app.services.game_state_manager.get_session") as mock_get_session:
|
||||
mock_get_session.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_get_session.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
await game_state_manager.archive_to_history(
|
||||
game_id=game_id,
|
||||
state=sample_game_state,
|
||||
winner_id=None, # NPC won, so no UUID
|
||||
winner_is_npc=True,
|
||||
end_reason=EndReason.NO_POKEMON,
|
||||
)
|
||||
|
||||
# Verify GameHistory has NPC winner flag
|
||||
added_obj = mock_session.add.call_args[0][0]
|
||||
assert isinstance(added_obj, GameHistory)
|
||||
assert added_obj.winner_is_npc is True
|
||||
assert added_obj.winner_id is None
|
||||
Loading…
Reference in New Issue
Block a user