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