Implement GameService.execute_action enhancements (GS-003)
Add forced action handling, turn boundary detection, and DB persistence: - Check for pending forced actions before allowing regular actions - Only specified player can act during forced action (except resign) - Only specified action type allowed during forced action - Detect turn boundaries (turn number OR current player change) - Persist to Postgres at turn boundaries for durability - Include pending_forced_action in GameActionResult for client New exceptions: ForcedActionRequiredError Tests: 11 new tests covering forced actions, turn boundaries, and pending action reporting. Total 47 tests for GameService. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3c75ee0e00
commit
cd3cc892f4
@ -100,8 +100,7 @@ class PrizeConfig(BaseModel):
|
|||||||
score points instead of taking prize cards. This simplifies the game while
|
score points instead of taking prize cards. This simplifies the game while
|
||||||
maintaining the knockout scoring mechanic.
|
maintaining the knockout scoring mechanic.
|
||||||
|
|
||||||
Knockout points are determined by the Pokemon's variant (EX, V, GX, etc.),
|
Knockout points are determined by the Pokemon's variant (EX, V, GX, etc.). A Basic EX is worth the same as a Stage 2 EX.
|
||||||
not by its evolution stage. A Basic EX is worth the same as a Stage 2 EX.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
count: Number of points needed to win (or prize cards if using classic rules).
|
count: Number of points needed to win (or prize cards if using classic rules).
|
||||||
|
|||||||
@ -401,6 +401,8 @@ class TurnManager:
|
|||||||
knockouts.append(active.instance_id)
|
knockouts.append(active.instance_id)
|
||||||
messages.append(f"{card_def.name} knocked out by status damage!")
|
messages.append(f"{card_def.name} knocked out by status damage!")
|
||||||
|
|
||||||
|
# TODO: Must check for opponent's active status damage (e.g. poison, burn) which could cause rare double-KO; if both players meet win condition between turns, game ends in tie
|
||||||
|
|
||||||
# Process knockouts BEFORE checking win conditions
|
# Process knockouts BEFORE checking win conditions
|
||||||
# The opponent (who will be the next player) scores points for status KOs
|
# The opponent (who will be the next player) scores points for status KOs
|
||||||
win_result = None
|
win_result = None
|
||||||
|
|||||||
@ -134,11 +134,48 @@ class GameCreationError(GameServiceError):
|
|||||||
super().__init__(f"Failed to create game: {reason}")
|
super().__init__(f"Failed to create game: {reason}")
|
||||||
|
|
||||||
|
|
||||||
|
class ForcedActionRequiredError(GameServiceError):
|
||||||
|
"""Raised when a forced action is required but a different action was attempted."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
game_id: str,
|
||||||
|
player_id: str,
|
||||||
|
required_action_type: str,
|
||||||
|
attempted_action_type: str,
|
||||||
|
) -> None:
|
||||||
|
self.game_id = game_id
|
||||||
|
self.player_id = player_id
|
||||||
|
self.required_action_type = required_action_type
|
||||||
|
self.attempted_action_type = attempted_action_type
|
||||||
|
super().__init__(
|
||||||
|
f"Forced action required: {required_action_type}, "
|
||||||
|
f"but {attempted_action_type} was attempted"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Result Types
|
# Result Types
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingForcedAction:
|
||||||
|
"""Information about a pending forced action for the client.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
player_id: The player who must take the action.
|
||||||
|
action_type: The type of action required.
|
||||||
|
reason: Human-readable explanation.
|
||||||
|
params: Additional parameters for the action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
player_id: str
|
||||||
|
action_type: str
|
||||||
|
reason: str
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GameActionResult:
|
class GameActionResult:
|
||||||
"""Result of executing a game action.
|
"""Result of executing a game action.
|
||||||
@ -152,6 +189,9 @@ class GameActionResult:
|
|||||||
game_over: Whether the game ended as a result.
|
game_over: Whether the game ended as a result.
|
||||||
winner_id: Winner's player ID if game ended with a winner.
|
winner_id: Winner's player ID if game ended with a winner.
|
||||||
end_reason: Reason the game ended, if applicable.
|
end_reason: Reason the game ended, if applicable.
|
||||||
|
turn_changed: Whether the turn changed as a result of this action.
|
||||||
|
current_player_id: The current player after action execution.
|
||||||
|
pending_forced_action: If set, the next action must be this forced action.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
@ -162,6 +202,9 @@ class GameActionResult:
|
|||||||
game_over: bool = False
|
game_over: bool = False
|
||||||
winner_id: str | None = None
|
winner_id: str | None = None
|
||||||
end_reason: GameEndReason | None = None
|
end_reason: GameEndReason | None = None
|
||||||
|
turn_changed: bool = False
|
||||||
|
current_player_id: str | None = None
|
||||||
|
pending_forced_action: PendingForcedAction | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -455,19 +498,28 @@ class GameService:
|
|||||||
persists the updated state. The GameEngine is created using
|
persists the updated state. The GameEngine is created using
|
||||||
the rules stored in the game state.
|
the rules stored in the game state.
|
||||||
|
|
||||||
|
Handles forced actions: if there's a pending forced action (e.g.,
|
||||||
|
select new active after KO), only that action type from the
|
||||||
|
specified player is allowed.
|
||||||
|
|
||||||
|
Persists to database at turn boundaries for durability.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
game_id: The game ID.
|
game_id: The game ID.
|
||||||
player_id: The acting player's ID.
|
player_id: The acting player's ID.
|
||||||
action: The action to execute.
|
action: The action to execute.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
GameActionResult with success status and state changes.
|
GameActionResult with success status, state changes, and
|
||||||
|
any pending forced actions.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GameNotFoundError: If game doesn't exist.
|
GameNotFoundError: If game doesn't exist.
|
||||||
PlayerNotInGameError: If player is not in the game.
|
PlayerNotInGameError: If player is not in the game.
|
||||||
GameAlreadyEndedError: If game has already ended.
|
GameAlreadyEndedError: If game has already ended.
|
||||||
NotPlayerTurnError: If it's not the player's turn.
|
NotPlayerTurnError: If it's not the player's turn.
|
||||||
|
ForcedActionRequiredError: If a forced action is required but
|
||||||
|
a different action was attempted.
|
||||||
InvalidActionError: If the action is invalid.
|
InvalidActionError: If the action is invalid.
|
||||||
"""
|
"""
|
||||||
# Load game state
|
# Load game state
|
||||||
@ -481,10 +533,23 @@ class GameService:
|
|||||||
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:
|
||||||
raise GameAlreadyEndedError(game_id)
|
raise GameAlreadyEndedError(game_id)
|
||||||
|
|
||||||
# Check it's player's turn (unless resignation, which can happen anytime)
|
# Check for forced actions (except resignation, which is always allowed)
|
||||||
if not isinstance(action, ResignAction) and state.current_player_id != player_id:
|
forced = state.get_current_forced_action()
|
||||||
|
if forced is not None and not isinstance(action, ResignAction):
|
||||||
|
# Only the specified player can act during a forced action
|
||||||
|
if player_id != forced.player_id:
|
||||||
|
raise NotPlayerTurnError(game_id, player_id, forced.player_id)
|
||||||
|
# Only the specified action type is allowed
|
||||||
|
if action.type != forced.action_type:
|
||||||
|
raise ForcedActionRequiredError(game_id, player_id, forced.action_type, action.type)
|
||||||
|
elif not isinstance(action, ResignAction) and state.current_player_id != player_id:
|
||||||
|
# Normal turn check (no forced action pending)
|
||||||
raise NotPlayerTurnError(game_id, player_id, state.current_player_id)
|
raise NotPlayerTurnError(game_id, player_id, state.current_player_id)
|
||||||
|
|
||||||
|
# Track turn state before action for boundary detection
|
||||||
|
turn_before = state.turn_number
|
||||||
|
player_before = state.current_player_id
|
||||||
|
|
||||||
# Create engine with this game's rules via factory
|
# Create engine with this game's rules via factory
|
||||||
engine = self._engine_factory(state)
|
engine = self._engine_factory(state)
|
||||||
|
|
||||||
@ -494,11 +559,16 @@ class GameService:
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
raise InvalidActionError(game_id, player_id, result.message)
|
raise InvalidActionError(game_id, player_id, result.message)
|
||||||
|
|
||||||
|
# Detect turn change
|
||||||
|
turn_changed = state.turn_number != turn_before or state.current_player_id != player_before
|
||||||
|
|
||||||
# Save state to cache (fast path)
|
# Save state to cache (fast path)
|
||||||
await self._state_manager.save_to_cache(state)
|
await self._state_manager.save_to_cache(state)
|
||||||
|
|
||||||
# Check if turn ended - persist to DB at turn boundaries
|
# Persist to DB at turn boundaries for durability
|
||||||
# TODO: Implement turn boundary detection for DB persistence
|
if turn_changed:
|
||||||
|
await self._state_manager.persist_to_db(state)
|
||||||
|
logger.debug(f"Turn boundary: persisted game {game_id} to DB")
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
action_result = GameActionResult(
|
action_result = GameActionResult(
|
||||||
@ -509,6 +579,18 @@ class GameService:
|
|||||||
state_changes={
|
state_changes={
|
||||||
"changes": result.state_changes,
|
"changes": result.state_changes,
|
||||||
},
|
},
|
||||||
|
turn_changed=turn_changed,
|
||||||
|
current_player_id=state.current_player_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include pending forced action if any
|
||||||
|
next_forced = state.get_current_forced_action()
|
||||||
|
if next_forced is not None:
|
||||||
|
action_result.pending_forced_action = PendingForcedAction(
|
||||||
|
player_id=next_forced.player_id,
|
||||||
|
action_type=next_forced.action_type,
|
||||||
|
reason=next_forced.reason,
|
||||||
|
params=next_forced.params,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for game over
|
# Check for game over
|
||||||
|
|||||||
@ -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": 7,
|
"completedTasks": 8,
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
||||||
},
|
},
|
||||||
@ -249,8 +249,8 @@
|
|||||||
"description": "Validate and execute player actions, persist state, return results",
|
"description": "Validate and execute player actions, persist state, return results",
|
||||||
"category": "services",
|
"category": "services",
|
||||||
"priority": 7,
|
"priority": 7,
|
||||||
"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"}
|
||||||
|
|||||||
@ -18,10 +18,11 @@ import pytest
|
|||||||
|
|
||||||
from app.core.engine import ActionResult
|
from app.core.engine import ActionResult
|
||||||
from app.core.enums import GameEndReason, TurnPhase
|
from app.core.enums import GameEndReason, TurnPhase
|
||||||
from app.core.models.actions import AttackAction, PassAction, ResignAction
|
from app.core.models.actions import AttackAction, PassAction, ResignAction, SelectActiveAction
|
||||||
from app.core.models.game_state import GameState, PlayerState
|
from app.core.models.game_state import ForcedAction, GameState, PlayerState
|
||||||
from app.core.win_conditions import WinResult
|
from app.core.win_conditions import WinResult
|
||||||
from app.services.game_service import (
|
from app.services.game_service import (
|
||||||
|
ForcedActionRequiredError,
|
||||||
GameAlreadyEndedError,
|
GameAlreadyEndedError,
|
||||||
GameCreationError,
|
GameCreationError,
|
||||||
GameNotFoundError,
|
GameNotFoundError,
|
||||||
@ -534,6 +535,352 @@ class TestExecuteAction:
|
|||||||
mock_state_manager.persist_to_db.assert_called_once()
|
mock_state_manager.persist_to_db.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestForcedActions:
|
||||||
|
"""Tests for forced action enforcement in execute_action.
|
||||||
|
|
||||||
|
When a forced action is pending (e.g., select new active after KO),
|
||||||
|
only the specified player can act and only with the specified action type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def game_state_with_forced_action(self) -> GameState:
|
||||||
|
"""Create a game state with a pending forced action.
|
||||||
|
|
||||||
|
The forced action requires player-1 to select a new active Pokemon,
|
||||||
|
simulating the situation after their active was knocked out.
|
||||||
|
"""
|
||||||
|
player1 = PlayerState(player_id="player-1")
|
||||||
|
player2 = PlayerState(player_id="player-2")
|
||||||
|
|
||||||
|
state = GameState(
|
||||||
|
game_id="game-123",
|
||||||
|
players={"player-1": player1, "player-2": player2},
|
||||||
|
current_player_id="player-2", # It's player-2's turn
|
||||||
|
turn_number=3,
|
||||||
|
phase=TurnPhase.MAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add forced action for player-1 (select new active after KO)
|
||||||
|
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-pokemon-1", "bench-pokemon-2"]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forced_action_wrong_player_raises_error(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
game_state_with_forced_action: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that wrong player attempting action during forced action raises error.
|
||||||
|
|
||||||
|
When player-1 has a forced action pending, player-2 cannot act
|
||||||
|
(even though it's player-2's normal turn) except for resignation.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
||||||
|
|
||||||
|
# player-2 tries to pass, but player-1 has forced action pending
|
||||||
|
with pytest.raises(NotPlayerTurnError) as exc_info:
|
||||||
|
await game_service.execute_action("game-123", "player-2", PassAction())
|
||||||
|
|
||||||
|
# Error should indicate player-1 must act (forced action player)
|
||||||
|
assert exc_info.value.player_id == "player-2"
|
||||||
|
assert exc_info.value.current_player_id == "player-1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forced_action_wrong_action_type_raises_error(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
game_state_with_forced_action: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that wrong action type during forced action raises error.
|
||||||
|
|
||||||
|
When a forced action requires 'select_active', attempting any other
|
||||||
|
action type (like 'pass') should raise ForcedActionRequiredError.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
||||||
|
|
||||||
|
# player-1 tries to pass instead of selecting active
|
||||||
|
with pytest.raises(ForcedActionRequiredError) as exc_info:
|
||||||
|
await game_service.execute_action("game-123", "player-1", PassAction())
|
||||||
|
|
||||||
|
assert exc_info.value.required_action_type == "select_active"
|
||||||
|
assert exc_info.value.attempted_action_type == "pass"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forced_action_correct_action_succeeds(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
game_state_with_forced_action: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that correct action during forced action succeeds.
|
||||||
|
|
||||||
|
When player-1 submits the required select_active action,
|
||||||
|
it should be executed successfully.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
||||||
|
mock_engine.execute_action.return_value = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="New active Pokemon selected",
|
||||||
|
)
|
||||||
|
|
||||||
|
action = SelectActiveAction(pokemon_id="bench-pokemon-1")
|
||||||
|
result = await game_service.execute_action("game-123", "player-1", action)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action_type == "select_active"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resign_allowed_during_forced_action(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
game_state_with_forced_action: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that resignation is always allowed, even during forced action.
|
||||||
|
|
||||||
|
A player should always be able to resign, regardless of forced
|
||||||
|
action state. This is a special exception to the forced action rule.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
||||||
|
mock_engine.execute_action.return_value = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Player resigned",
|
||||||
|
win_result=WinResult(
|
||||||
|
winner_id="player-2",
|
||||||
|
loser_id="player-1",
|
||||||
|
end_reason=GameEndReason.RESIGNATION,
|
||||||
|
reason="Player resigned",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# player-1 resigns instead of selecting active
|
||||||
|
result = await game_service.execute_action("game-123", "player-1", ResignAction())
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.game_over is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_opponent_resign_allowed_during_forced_action(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
game_state_with_forced_action: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that opponent can resign while other player has forced action.
|
||||||
|
|
||||||
|
When player-1 has a forced action pending (select active), player-2
|
||||||
|
should still be able to resign. Resignation is always allowed for
|
||||||
|
any player at any time.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = game_state_with_forced_action
|
||||||
|
mock_engine.execute_action.return_value = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Player resigned",
|
||||||
|
win_result=WinResult(
|
||||||
|
winner_id="player-1",
|
||||||
|
loser_id="player-2",
|
||||||
|
end_reason=GameEndReason.RESIGNATION,
|
||||||
|
reason="Player resigned",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# player-2 resigns while player-1 has forced action pending
|
||||||
|
result = await game_service.execute_action("game-123", "player-2", ResignAction())
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.game_over is True
|
||||||
|
assert result.winner_id == "player-1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTurnBoundaryPersistence:
|
||||||
|
"""Tests for turn boundary detection and database persistence.
|
||||||
|
|
||||||
|
The game state should be persisted to the database when the turn
|
||||||
|
changes (either turn number or current player changes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_change_triggers_db_persistence(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
sample_game_state: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that turn change triggers database persistence.
|
||||||
|
|
||||||
|
When an action causes the turn to change (e.g., pass action
|
||||||
|
ends turn), the state should be persisted to the database
|
||||||
|
for durability.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = sample_game_state
|
||||||
|
|
||||||
|
def mock_execute(state, player_id, action):
|
||||||
|
# Simulate turn change: pass action ends turn
|
||||||
|
state.current_player_id = "player-2"
|
||||||
|
state.turn_number = 2
|
||||||
|
return ActionResult(success=True, message="Turn ended")
|
||||||
|
|
||||||
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
||||||
|
|
||||||
|
result = await game_service.execute_action("game-123", "player-1", PassAction())
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.turn_changed is True
|
||||||
|
assert result.current_player_id == "player-2"
|
||||||
|
|
||||||
|
# DB persistence should have been called due to turn change
|
||||||
|
mock_state_manager.persist_to_db.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_turn_change_skips_db_persistence(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
sample_game_state: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that actions without turn change don't persist to DB.
|
||||||
|
|
||||||
|
For performance, we only persist to DB at turn boundaries.
|
||||||
|
Actions within the same turn only update the cache.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = sample_game_state
|
||||||
|
mock_engine.execute_action.return_value = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Attack executed",
|
||||||
|
state_changes=[{"type": "damage", "amount": 30}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await game_service.execute_action(
|
||||||
|
"game-123", "player-1", AttackAction(attack_index=0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.turn_changed is False
|
||||||
|
|
||||||
|
# Cache should be updated, but DB should NOT be persisted
|
||||||
|
mock_state_manager.save_to_cache.assert_called_once()
|
||||||
|
mock_state_manager.persist_to_db.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_current_player_change_is_turn_change(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
sample_game_state: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that current player change counts as turn change.
|
||||||
|
|
||||||
|
Turn boundaries are detected by either turn number change or
|
||||||
|
current player change (e.g., forced action completion might
|
||||||
|
return control to another player).
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = sample_game_state
|
||||||
|
|
||||||
|
def mock_execute(state, player_id, action):
|
||||||
|
# Only current player changes, turn number stays same
|
||||||
|
state.current_player_id = "player-2"
|
||||||
|
return ActionResult(success=True, message="Player changed")
|
||||||
|
|
||||||
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
||||||
|
|
||||||
|
result = await game_service.execute_action("game-123", "player-1", PassAction())
|
||||||
|
|
||||||
|
assert result.turn_changed is True
|
||||||
|
mock_state_manager.persist_to_db.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingForcedActionInResult:
|
||||||
|
"""Tests for pending forced action inclusion in GameActionResult.
|
||||||
|
|
||||||
|
After executing an action, if there's a new forced action pending,
|
||||||
|
it should be included in the result so the client knows what's next.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_forced_action_included_in_result(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
sample_game_state: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that pending forced action is included in result.
|
||||||
|
|
||||||
|
When an action results in a forced action being queued (e.g.,
|
||||||
|
knockout triggers select_active), the result should include
|
||||||
|
the pending forced action details for the client.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = sample_game_state
|
||||||
|
|
||||||
|
def mock_execute(state, player_id, action):
|
||||||
|
# Action execution adds a forced action (simulating KO)
|
||||||
|
state.forced_actions = [
|
||||||
|
ForcedAction(
|
||||||
|
player_id="player-2",
|
||||||
|
action_type="select_active",
|
||||||
|
reason="Your active Pokemon was knocked out.",
|
||||||
|
params={"available_bench_ids": ["bench-1", "bench-2"]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Attack knocked out opponent's Pokemon",
|
||||||
|
state_changes=[{"type": "knockout"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_engine.execute_action = AsyncMock(side_effect=mock_execute)
|
||||||
|
|
||||||
|
result = await game_service.execute_action(
|
||||||
|
"game-123", "player-1", AttackAction(attack_index=0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.pending_forced_action is not None
|
||||||
|
assert result.pending_forced_action.player_id == "player-2"
|
||||||
|
assert result.pending_forced_action.action_type == "select_active"
|
||||||
|
assert result.pending_forced_action.reason == "Your active Pokemon was knocked out."
|
||||||
|
assert "bench-1" in result.pending_forced_action.params["available_bench_ids"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_pending_forced_action_when_queue_empty(
|
||||||
|
self,
|
||||||
|
game_service: GameService,
|
||||||
|
mock_state_manager: AsyncMock,
|
||||||
|
mock_engine: MagicMock,
|
||||||
|
sample_game_state: GameState,
|
||||||
|
) -> None:
|
||||||
|
"""Test that pending_forced_action is None when no forced action.
|
||||||
|
|
||||||
|
Normal actions that don't trigger forced actions should return
|
||||||
|
None for the pending_forced_action field.
|
||||||
|
"""
|
||||||
|
mock_state_manager.load_state.return_value = sample_game_state
|
||||||
|
mock_engine.execute_action.return_value = ActionResult(
|
||||||
|
success=True,
|
||||||
|
message="Action executed normally",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await game_service.execute_action("game-123", "player-1", PassAction())
|
||||||
|
|
||||||
|
assert result.pending_forced_action is None
|
||||||
|
|
||||||
|
|
||||||
class TestResignGame:
|
class TestResignGame:
|
||||||
"""Tests for the resign_game convenience method."""
|
"""Tests for the resign_game convenience method."""
|
||||||
|
|
||||||
@ -990,3 +1337,15 @@ class TestExceptionMessages:
|
|||||||
error = GameAlreadyEndedError("game-123")
|
error = GameAlreadyEndedError("game-123")
|
||||||
assert "game-123" in str(error)
|
assert "game-123" in str(error)
|
||||||
assert "ended" in str(error).lower()
|
assert "ended" in str(error).lower()
|
||||||
|
|
||||||
|
def test_forced_action_required_error_message(self) -> None:
|
||||||
|
"""Test ForcedActionRequiredError has descriptive message.
|
||||||
|
|
||||||
|
The error should clearly indicate what action was required
|
||||||
|
and what action was incorrectly attempted.
|
||||||
|
"""
|
||||||
|
error = ForcedActionRequiredError("game-123", "player-1", "select_active", "pass")
|
||||||
|
assert "select_active" in str(error)
|
||||||
|
assert "pass" in str(error)
|
||||||
|
assert error.required_action_type == "select_active"
|
||||||
|
assert error.attempted_action_type == "pass"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user