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
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:
success: Whether the join succeeded.
game_id: The game ID.
player_id: The joining player's ID.
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.
"""
@ -225,6 +230,8 @@ class GameJoinResult:
player_id: str
visible_state: VisibleGameState | None = None
is_your_turn: bool = False
game_over: bool = False
pending_forced_action: PendingForcedAction | None = None
message: str = ""
@ -436,13 +443,18 @@ class GameService:
Loads the game state and returns the player's visible view.
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:
game_id: The game to join.
player_id: The joining player's ID.
last_event_id: Last event ID for reconnection replay (future use).
Will be used to replay missed events after reconnect.
Returns:
GameJoinResult with the visible state.
GameJoinResult with the visible state and any pending actions.
"""
try:
state = await self.get_game_state(game_id)
@ -462,19 +474,37 @@ class GameService:
message="You are not a participant in this game",
)
visible = get_visible_state(state, player_id)
# Check if game already ended
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(
success=True,
game_id=game_id,
player_id=player_id,
visible_state=visible,
is_your_turn=False,
game_over=True,
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}")
@ -483,7 +513,9 @@ class GameService:
game_id=game_id,
player_id=player_id,
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(

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": 8,
"completedTasks": 9,
"status": "in_progress",
"masterPlan": "../PROJECT_PLAN_MASTER.json"
},
@ -274,8 +274,8 @@
"description": "Handle players joining/rejoining games",
"category": "services",
"priority": 8,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["GS-002"],
"files": [
{"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.
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.end_reason = GameEndReason.PRIZES_TAKEN
@ -338,8 +338,89 @@ class TestJoinGame:
assert result.success is True
assert result.is_your_turn is False
assert result.game_over is True
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:
"""Tests for the execute_action method.