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:
parent
ce8a36b14c
commit
d5460ff418
@ -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(
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user