Enhance GameService.join_game for reconnection support (GS-004)

Add pending forced action and game_over fields to GameJoinResult:
- pending_forced_action: Included when player must complete a forced
  action (e.g., select new active after KO). Essential for reconnection
  so client knows what action is required.
- game_over: Boolean indicating if game has already ended.
- is_your_turn: Now True when player has pending forced action, even if
  it's technically opponent's turn.

The join_game method now handles both initial joins and reconnections
(resume). The last_event_id parameter is accepted for future event
replay support.

Tests: 4 new tests for forced action handling and game_over flag.
Total 51 tests for GameService.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-29 19:30:44 -06:00
parent ce8a36b14c
commit d5460ff418
3 changed files with 123 additions and 10 deletions

View File

@ -209,14 +209,19 @@ class GameActionResult:
@dataclass @dataclass
class GameJoinResult: class GameJoinResult:
"""Result of joining a game. """Result of joining or rejoining a game.
Used when a player connects/reconnects to a game session. Contains
everything needed to render the game UI and know what action is required.
Attributes: Attributes:
success: Whether the join succeeded. success: Whether the join succeeded.
game_id: The game ID. game_id: The game ID.
player_id: The joining player's ID. player_id: The joining player's ID.
visible_state: The game state visible to the player. visible_state: The game state visible to the player.
is_your_turn: Whether it's this player's turn. is_your_turn: Whether it's this player's turn (or forced action required).
game_over: Whether the game has already ended.
pending_forced_action: If set, this action must be taken before any other.
message: Additional information or error message. message: Additional information or error message.
""" """
@ -225,6 +230,8 @@ class GameJoinResult:
player_id: str player_id: str
visible_state: VisibleGameState | None = None visible_state: VisibleGameState | None = None
is_your_turn: bool = False is_your_turn: bool = False
game_over: bool = False
pending_forced_action: PendingForcedAction | None = None
message: str = "" message: str = ""
@ -436,13 +443,18 @@ class GameService:
Loads the game state and returns the player's visible view. Loads the game state and returns the player's visible view.
Used when a player connects or reconnects to a game. Used when a player connects or reconnects to a game.
This method handles both initial joins and reconnections after
disconnect. On reconnect, the full current state is returned
including any pending forced actions that the player must complete.
Args: Args:
game_id: The game to join. game_id: The game to join.
player_id: The joining player's ID. player_id: The joining player's ID.
last_event_id: Last event ID for reconnection replay (future use). last_event_id: Last event ID for reconnection replay (future use).
Will be used to replay missed events after reconnect.
Returns: Returns:
GameJoinResult with the visible state. GameJoinResult with the visible state and any pending actions.
""" """
try: try:
state = await self.get_game_state(game_id) state = await self.get_game_state(game_id)
@ -462,19 +474,37 @@ class GameService:
message="You are not a participant in this game", message="You are not a participant in this game",
) )
visible = get_visible_state(state, player_id)
# Check if game already ended # Check if game already ended
if state.winner_id is not None or state.end_reason is not None: if state.winner_id is not None or state.end_reason is not None:
visible = get_visible_state(state, player_id) logger.info(f"Player {player_id} joined ended game {game_id}")
return GameJoinResult( return GameJoinResult(
success=True, success=True,
game_id=game_id, game_id=game_id,
player_id=player_id, player_id=player_id,
visible_state=visible, visible_state=visible,
is_your_turn=False, is_your_turn=False,
game_over=True,
message="Game has ended", message="Game has ended",
) )
visible = get_visible_state(state, player_id) # Check for pending forced action
forced = state.get_current_forced_action()
pending_forced: PendingForcedAction | None = None
if forced is not None:
pending_forced = PendingForcedAction(
player_id=forced.player_id,
action_type=forced.action_type,
reason=forced.reason,
params=forced.params or {},
)
# Determine if it's this player's turn
# It's their turn if: normal turn OR they have a forced action
is_turn = state.current_player_id == player_id
if forced is not None and forced.player_id == player_id:
is_turn = True
logger.info(f"Player {player_id} joined game {game_id}") logger.info(f"Player {player_id} joined game {game_id}")
@ -483,7 +513,9 @@ class GameService:
game_id=game_id, game_id=game_id,
player_id=player_id, player_id=player_id,
visible_state=visible, visible_state=visible,
is_your_turn=state.current_player_id == player_id, is_your_turn=is_turn,
game_over=False,
pending_forced_action=pending_forced,
) )
async def execute_action( async def execute_action(

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": 8, "completedTasks": 9,
"status": "in_progress", "status": "in_progress",
"masterPlan": "../PROJECT_PLAN_MASTER.json" "masterPlan": "../PROJECT_PLAN_MASTER.json"
}, },
@ -274,8 +274,8 @@
"description": "Handle players joining/rejoining games", "description": "Handle players joining/rejoining games",
"category": "services", "category": "services",
"priority": 8, "priority": 8,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["GS-002"], "dependencies": ["GS-002"],
"files": [ "files": [
{"path": "app/services/game_service.py", "status": "modify"} {"path": "app/services/game_service.py", "status": "modify"}

View File

@ -328,7 +328,7 @@ class TestJoinGame:
"""Test joining an ended game still succeeds but indicates game over. """Test joining an ended game still succeeds but indicates game over.
Players should be able to rejoin ended games to see the final Players should be able to rejoin ended games to see the final
state, but is_your_turn should be False. state, but is_your_turn should be False and game_over should be True.
""" """
sample_game_state.winner_id = "player-1" sample_game_state.winner_id = "player-1"
sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN
@ -338,8 +338,89 @@ class TestJoinGame:
assert result.success is True assert result.success is True
assert result.is_your_turn is False assert result.is_your_turn is False
assert result.game_over is True
assert "ended" in result.message.lower() assert "ended" in result.message.lower()
@pytest.mark.asyncio
async def test_join_game_includes_pending_forced_action(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that join_game includes pending forced action in result.
When a player rejoins mid-game and there's a pending forced action
(e.g., select new active after KO), the result should include that
information so the client knows what action is required.
"""
# Add forced action to the game state
sample_game_state.forced_actions = [
ForcedAction(
player_id="player-1",
action_type="select_active",
reason="Your active Pokemon was knocked out. Select a new active.",
params={"available_bench_ids": ["bench-1", "bench-2"]},
)
]
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "player-1")
assert result.success is True
assert result.pending_forced_action is not None
assert result.pending_forced_action.player_id == "player-1"
assert result.pending_forced_action.action_type == "select_active"
@pytest.mark.asyncio
async def test_join_game_forced_action_sets_is_your_turn(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that is_your_turn is True when player has forced action.
Even if it's technically the opponent's turn, if this player has
a forced action pending, is_your_turn should be True so they know
they must act.
"""
# Set current player to player-2, but forced action is for player-1
sample_game_state.current_player_id = "player-2"
sample_game_state.forced_actions = [
ForcedAction(
player_id="player-1",
action_type="select_active",
reason="Your active Pokemon was knocked out.",
)
]
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "player-1")
assert result.success is True
# Player-1 has forced action, so it's their turn to act
assert result.is_your_turn is True
assert result.pending_forced_action is not None
@pytest.mark.asyncio
async def test_join_game_active_game_not_over(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that game_over is False for active games.
When joining an ongoing game, game_over should be False.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "player-1")
assert result.success is True
assert result.game_over is False
class TestExecuteAction: class TestExecuteAction:
"""Tests for the execute_action method. """Tests for the execute_action method.