From 529c5b1b9950b60e3d15e33a5900017e53698703 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Feb 2026 09:33:58 -0600 Subject: [PATCH] CLAUDE: Implement uncapped hit interactive decision tree (Issue #6) Add full multi-step decision workflow for SINGLE_UNCAPPED and DOUBLE_UNCAPPED outcomes, replacing the previous stub that fell through to basic single/double advancement. The decision tree follows the same interactive pattern as X-Check resolution with 5 phases: lead runner advance, defensive throw, trail runner advance, throw target selection, and safe/out speed check. - game_models.py: PendingUncappedHit model, 5 new decision phases - game_engine.py: initiate_uncapped_hit(), 5 submit methods, 3 result builders - handlers.py: 5 new WebSocket event handlers - ai_opponent.py: 5 AI decision stubs (conservative defaults) - play_resolver.py: Updated TODO comments for fallback paths - 80 new backend tests (2481 total): workflow (49), handlers (23), truth tables (8) - Fix GameplayPanel.spec.ts: add missing Pinia setup, fix component references Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 5 +- backend/CLAUDE.md | 4 +- backend/app/core/CLAUDE.md | 2 +- backend/app/core/ai_opponent.py | 59 + backend/app/core/game_engine.py | 897 ++++++++++++- backend/app/core/play_resolver.py | 14 +- backend/app/models/game_models.py | 132 ++ backend/app/websocket/CLAUDE.md | 13 +- backend/app/websocket/handlers.py | 292 +++++ backend/tests/CLAUDE.md | 12 +- .../unit/core/test_uncapped_hit_workflow.py | 1115 +++++++++++++++++ .../truth_tables/test_tt_uncapped_hits.py | 124 ++ .../websocket/test_uncapped_hit_handlers.py | 385 ++++++ .../Decisions/DefensiveSetup.spec.ts | 34 +- .../components/Gameplay/GameplayPanel.spec.ts | 244 ++-- 15 files changed, 3102 insertions(+), 230 deletions(-) create mode 100644 backend/tests/unit/core/test_uncapped_hit_workflow.py create mode 100644 backend/tests/unit/core/truth_tables/test_tt_uncapped_hits.py create mode 100644 backend/tests/unit/websocket/test_uncapped_hit_handlers.py diff --git a/CLAUDE.md b/CLAUDE.md index 99e4d51..278246c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,13 +286,14 @@ The `start.sh` script handles this automatically based on mode. **Phase 3E-Final**: ✅ **COMPLETE** (2025-01-10) Backend is production-ready for frontend integration: -- ✅ All 15 WebSocket event handlers implemented +- ✅ All 20 WebSocket event handlers implemented - ✅ Strategic decisions (defensive/offensive) - ✅ Manual outcome workflow (dice rolling + card reading) - ✅ Player substitutions (3 types) - ✅ Box score statistics (materialized views) - ✅ Position ratings integration (PD league) -- ✅ 730/731 tests passing (99.9%) +- ✅ Uncapped hit interactive decision tree (SINGLE_UNCAPPED, DOUBLE_UNCAPPED) +- ✅ 2481/2481 tests passing (100%) **Next Phase**: Vue 3 + Nuxt 3 frontend implementation with Socket.io client diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 329abe2..c0dbd39 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -38,7 +38,7 @@ uv run python -m app.main # Start server at localhost:8000 ### Testing ```bash -uv run pytest tests/unit/ -v # All unit tests (836 passing) +uv run pytest tests/unit/ -v # All unit tests (2481 passing) uv run python -m terminal_client # Interactive REPL ``` @@ -144,4 +144,4 @@ uv run pytest tests/unit/ -q # Must show all passing --- -**Tests**: 836 passing | **Phase**: 3E-Final Complete | **Updated**: 2025-01-27 +**Tests**: 2481 passing | **Phase**: 3E-Final Complete | **Updated**: 2026-02-11 diff --git a/backend/app/core/CLAUDE.md b/backend/app/core/CLAUDE.md index 3ed1038..d76e63c 100644 --- a/backend/app/core/CLAUDE.md +++ b/backend/app/core/CLAUDE.md @@ -139,4 +139,4 @@ uv run python -m terminal_client --- -**Tests**: 739/739 passing | **Last Updated**: 2025-01-19 +**Tests**: 2481/2481 passing | **Last Updated**: 2026-02-11 diff --git a/backend/app/core/ai_opponent.py b/backend/app/core/ai_opponent.py index 3cd977e..64357b4 100644 --- a/backend/app/core/ai_opponent.py +++ b/backend/app/core/ai_opponent.py @@ -117,6 +117,65 @@ class AIOpponent: ) return decision + # ======================================================================== + # UNCAPPED HIT DECISIONS + # ======================================================================== + + async def decide_uncapped_lead_advance( + self, state: GameState, pending: "PendingUncappedHit" + ) -> bool: + """ + AI decision: should lead runner attempt advance on uncapped hit? + + Conservative default: don't risk the runner. + """ + logger.debug(f"AI uncapped lead advance decision for game {state.game_id}") + return False + + async def decide_uncapped_defensive_throw( + self, state: GameState, pending: "PendingUncappedHit" + ) -> bool: + """ + AI decision: should defense throw to the base? + + Aggressive default: always challenge the runner. + """ + logger.debug(f"AI uncapped defensive throw decision for game {state.game_id}") + return True + + async def decide_uncapped_trail_advance( + self, state: GameState, pending: "PendingUncappedHit" + ) -> bool: + """ + AI decision: should trail runner attempt advance on uncapped hit? + + Conservative default: don't risk the trail runner. + """ + logger.debug(f"AI uncapped trail advance decision for game {state.game_id}") + return False + + async def decide_uncapped_throw_target( + self, state: GameState, pending: "PendingUncappedHit" + ) -> str: + """ + AI decision: throw at lead or trail runner? + + Default: target the lead runner (higher-value out). + """ + logger.debug(f"AI uncapped throw target decision for game {state.game_id}") + return "lead" + + async def decide_uncapped_safe_out( + self, state: GameState, pending: "PendingUncappedHit" + ) -> str: + """ + AI decision: declare runner safe or out? + + Offensive AI always wants the runner safe. + """ + logger.debug(f"AI uncapped safe/out decision for game {state.game_id}") + return "safe" + def _should_attempt_steal(self, state: GameState) -> bool: """ Determine if AI should attempt a steal (Week 9). diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index 3bccfa7..cd4cee7 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -31,6 +31,7 @@ from app.models.game_models import ( DefensiveDecision, GameState, OffensiveDecision, + PendingUncappedHit, PendingXCheck, ) from app.services import PlayStatCalculator @@ -62,8 +63,28 @@ class GameEngine: self._connection_manager = connection_manager logger.info("WebSocket connection manager configured for game engine") + # Phases where the OFFENSIVE team (batting) decides + _OFFENSIVE_PHASES = { + "awaiting_offensive", + "awaiting_uncapped_lead_advance", + "awaiting_uncapped_trail_advance", + "awaiting_uncapped_safe_out", + } + # Phases where the DEFENSIVE team (fielding) decides + _DEFENSIVE_PHASES = { + "awaiting_defensive", + "awaiting_uncapped_defensive_throw", + "awaiting_uncapped_throw_target", + } + async def _emit_decision_required( - self, game_id: UUID, state: GameState, phase: str, timeout_seconds: int = 300 + self, + game_id: UUID, + state: GameState, + phase: str, + timeout_seconds: int = 300, + data: dict | None = None, + **kwargs, ): """ Emit decision_required event to notify frontend a decision is needed. @@ -71,34 +92,45 @@ class GameEngine: Args: game_id: Game identifier state: Current game state - phase: Decision phase ('awaiting_defensive' or 'awaiting_offensive') + phase: Decision phase (e.g. 'awaiting_defensive', 'awaiting_uncapped_lead_advance') timeout_seconds: Decision timeout in seconds (default 5 minutes) + data: Optional extra data dict to include in the payload + **kwargs: Absorbs legacy keyword args (e.g. decision_type from x-check) """ if not self._connection_manager: logger.warning("No connection manager - cannot emit decision_required event") return + # Handle legacy kwarg from x-check calls + if "decision_type" in kwargs and not phase: + phase = kwargs["decision_type"] + # Determine which team needs to decide - if phase == "awaiting_defensive": - # Fielding team = home if top, away if bottom + if phase in self._DEFENSIVE_PHASES: role = "home" if state.half == "top" else "away" - elif phase == "awaiting_offensive": - # Batting team = away if top, home if bottom + elif phase in self._OFFENSIVE_PHASES: role = "away" if state.half == "top" else "home" + elif phase == "awaiting_x_check_result": + # X-check: defensive player selects result + role = "home" if state.half == "top" else "away" else: logger.warning(f"Unknown decision phase for emission: {phase}") return + payload = { + "phase": phase, + "role": role, + "timeout_seconds": timeout_seconds, + "message": f"{role.title()} team: {phase.replace('_', ' ').replace('awaiting ', '').title()} decision required", + } + if data: + payload["data"] = data + try: await self._connection_manager.broadcast_to_game( str(game_id), "decision_required", - { - "phase": phase, - "role": role, - "timeout_seconds": timeout_seconds, - "message": f"{role.title()} team: {phase.replace('_', ' ').title()} decision required" - } + payload, ) logger.info(f"Emitted decision_required for game {game_id}: phase={phase}, role={role}") except (ConnectionError, OSError) as e: @@ -778,6 +810,25 @@ class GameEngine: x_check_details=None, # Will be populated when resolved ) + # Check for uncapped hit outcomes - route to interactive decision tree + if outcome in (PlayOutcome.SINGLE_UNCAPPED, PlayOutcome.DOUBLE_UNCAPPED): + if self._uncapped_needs_decision(state, outcome): + await self.initiate_uncapped_hit(game_id, outcome, hit_location, ab_roll) + # Return placeholder - actual resolution happens through decision workflow + return PlayResult( + outcome=outcome, + outs_recorded=0, + runs_scored=0, + batter_result=None, + runners_advanced=[], + description=f"Uncapped {'single' if outcome == PlayOutcome.SINGLE_UNCAPPED else 'double'} - awaiting runner decisions", + ab_roll=ab_roll, + hit_location=hit_location, + is_hit=True, + is_out=False, + is_walk=False, + ) + # NOTE: Business rule validation (e.g., when hit_location is required based on # game state) is handled in PlayResolver, not here. The transport layer should # not make business logic decisions about contextual requirements. @@ -1064,6 +1115,828 @@ class GameEngine: f"result={pending.selected_result}, error={pending.error_result}" ) + # ============================================================================ + # INTERACTIVE UNCAPPED HIT WORKFLOW + # ============================================================================ + + def _uncapped_needs_decision(self, state: GameState, outcome: PlayOutcome) -> bool: + """ + Determine if an uncapped hit requires interactive runner decisions. + + SINGLE_UNCAPPED: needs decision if R1 or R2 exists (eligible lead runner) + DOUBLE_UNCAPPED: needs decision if R1 exists (R1 is lead attempting HOME) + + Args: + state: Current game state + outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED + + Returns: + True if interactive decision tree is needed + """ + if outcome == PlayOutcome.SINGLE_UNCAPPED: + return state.on_first is not None or state.on_second is not None + if outcome == PlayOutcome.DOUBLE_UNCAPPED: + return state.on_first is not None + return False + + async def initiate_uncapped_hit( + self, + game_id: UUID, + outcome: PlayOutcome, + hit_location: str | None, + ab_roll: "AbRoll", + ) -> None: + """ + Initiate interactive uncapped hit decision workflow. + + Identifies lead/trail runners, records auto-scoring runners, + creates PendingUncappedHit, and emits first decision prompt. + + Args: + game_id: Game ID + outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED + hit_location: Outfield position (LF, CF, RF) + ab_roll: The at-bat roll for audit trail + """ + async with state_manager.game_lock(game_id): + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") + + game_validator.validate_game_active(state) + + is_single = outcome == PlayOutcome.SINGLE_UNCAPPED + hit_type = "single" if is_single else "double" + batter_base = 1 if is_single else 2 + + # Default hit_location to CF if not provided + location = hit_location or "CF" + + auto_runners: list[tuple[int, int, int]] = [] + + if is_single: + # R3 always scores on any single + if state.on_third: + auto_runners.append((3, 4, state.on_third.lineup_id)) + + # Identify lead and trail runners + if state.on_second: + # Lead = R2 attempting HOME + lead_base = 2 + lead_lid = state.on_second.lineup_id + lead_target = 4 # HOME + + # Trail = R1 if exists, else batter + if state.on_first: + trail_base = 1 + trail_lid = state.on_first.lineup_id + trail_target = 3 # R1 attempting 3rd + else: + trail_base = 0 # batter + trail_lid = state.current_batter.lineup_id + trail_target = 2 # batter attempting 2nd + elif state.on_first: + # No R2, Lead = R1 attempting 3RD + lead_base = 1 + lead_lid = state.on_first.lineup_id + lead_target = 3 + + # Trail = batter attempting 2nd + trail_base = 0 + trail_lid = state.current_batter.lineup_id + trail_target = 2 + else: + # Should not reach here (_uncapped_needs_decision checks) + raise ValueError("SINGLE_UNCAPPED with no R1 or R2 should use fallback") + else: + # DOUBLE_UNCAPPED + # R3 and R2 always score on any double + if state.on_third: + auto_runners.append((3, 4, state.on_third.lineup_id)) + if state.on_second: + auto_runners.append((2, 4, state.on_second.lineup_id)) + + if state.on_first: + # Lead = R1 attempting HOME + lead_base = 1 + lead_lid = state.on_first.lineup_id + lead_target = 4 # HOME + + # Trail = batter attempting 3RD + trail_base = 0 + trail_lid = state.current_batter.lineup_id + trail_target = 3 + else: + # Should not reach here + raise ValueError("DOUBLE_UNCAPPED with no R1 should use fallback") + + # Create pending uncapped hit state + pending = PendingUncappedHit( + hit_type=hit_type, + hit_location=location, + ab_roll_id=ab_roll.roll_id, + lead_runner_base=lead_base, + lead_runner_lineup_id=lead_lid, + lead_target_base=lead_target, + trail_runner_base=trail_base, + trail_runner_lineup_id=trail_lid, + trail_target_base=trail_target, + auto_runners=auto_runners, + batter_base=batter_base, + batter_lineup_id=state.current_batter.lineup_id, + ) + + # Store in state + state.pending_uncapped_hit = pending + state.decision_phase = "awaiting_uncapped_lead_advance" + state.pending_decision = "uncapped_lead_advance" + + state_manager.update_state(game_id, state) + + logger.info( + f"Uncapped {hit_type} initiated for game {game_id}: " + f"lead=base{lead_base}→{lead_target}, trail=base{trail_base}→{trail_target}" + ) + + # Check if offensive team is AI + if state.is_batting_team_ai(): + advance = await ai_opponent.decide_uncapped_lead_advance(state, pending) + await self.submit_uncapped_lead_advance(game_id, advance) + return + + # Emit decision_required for offensive team + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_lead_advance", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "hit_type": hit_type, + "hit_location": location, + "lead_runner_base": lead_base, + "lead_runner_lineup_id": lead_lid, + "lead_target_base": lead_target, + "auto_runners": auto_runners, + }, + ) + + async def submit_uncapped_lead_advance( + self, game_id: UUID, advance: bool + ) -> None: + """ + Submit offensive decision: will lead runner attempt advance? + + If NO: fallback to standard SI*/DO** advancement, finalize immediately. + If YES: transition to awaiting_uncapped_defensive_throw. + """ + async with state_manager.game_lock(game_id): + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") + + pending = state.pending_uncapped_hit + if not pending: + raise ValueError("No pending uncapped hit") + + if state.decision_phase != "awaiting_uncapped_lead_advance": + raise ValueError( + f"Wrong phase: expected awaiting_uncapped_lead_advance, " + f"got {state.decision_phase}" + ) + + pending.lead_advance = advance + + if not advance: + # Lead runner declines → fallback to standard advancement, finalize + ab_roll = state.pending_manual_roll + if not ab_roll: + raise ValueError("No pending manual roll found") + + result = self._build_uncapped_fallback_result(state, pending, ab_roll) + await self._finalize_uncapped_hit(state, pending, ab_roll, result) + return + + # Lead runner advances → ask defensive team about throwing + state.decision_phase = "awaiting_uncapped_defensive_throw" + state.pending_decision = "uncapped_defensive_throw" + state_manager.update_state(game_id, state) + + # Check if defensive team is AI + if state.is_fielding_team_ai(): + will_throw = await ai_opponent.decide_uncapped_defensive_throw( + state, pending + ) + await self.submit_uncapped_defensive_throw(game_id, will_throw) + return + + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_defensive_throw", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "lead_runner_base": pending.lead_runner_base, + "lead_target_base": pending.lead_target_base, + "lead_runner_lineup_id": pending.lead_runner_lineup_id, + "hit_location": pending.hit_location, + }, + ) + + async def submit_uncapped_defensive_throw( + self, game_id: UUID, will_throw: bool + ) -> None: + """ + Submit defensive decision: will you throw to the base? + + If NO: lead runner safe, standard advancement, finalize. + If YES and trail runner exists: transition to awaiting_uncapped_trail_advance. + If YES and no trail: roll d20, transition to awaiting_uncapped_safe_out. + """ + async with state_manager.game_lock(game_id): + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") + + pending = state.pending_uncapped_hit + if not pending: + raise ValueError("No pending uncapped hit") + + if state.decision_phase != "awaiting_uncapped_defensive_throw": + raise ValueError( + f"Wrong phase: expected awaiting_uncapped_defensive_throw, " + f"got {state.decision_phase}" + ) + + pending.defensive_throw = will_throw + + if not will_throw: + # Defense declines throw → lead runner advances safely, finalize + ab_roll = state.pending_manual_roll + if not ab_roll: + raise ValueError("No pending manual roll found") + + result = self._build_uncapped_no_throw_result(state, pending, ab_roll) + await self._finalize_uncapped_hit(state, pending, ab_roll, result) + return + + # Defense throws → check for trail runner + has_trail = pending.trail_runner_base is not None + + if not has_trail: + # No trail runner → roll d20 for lead runner speed check + d20 = dice_system.roll_d20( + game_id=game_id, + team_id=state.get_fielding_team_id(), + player_id=None, + ) + pending.speed_check_d20 = d20 + pending.speed_check_runner = "lead" + state.decision_phase = "awaiting_uncapped_safe_out" + state.pending_decision = "uncapped_safe_out" + state_manager.update_state(game_id, state) + + # Check if offensive team is AI + if state.is_batting_team_ai(): + result = await ai_opponent.decide_uncapped_safe_out(state, pending) + await self.submit_uncapped_safe_out(game_id, result) + return + + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_safe_out", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "d20_roll": d20, + "runner": "lead", + "runner_base": pending.lead_runner_base, + "target_base": pending.lead_target_base, + "runner_lineup_id": pending.lead_runner_lineup_id, + "hit_location": pending.hit_location, + }, + ) + else: + # Trail runner exists → ask offensive about trail advance + state.decision_phase = "awaiting_uncapped_trail_advance" + state.pending_decision = "uncapped_trail_advance" + state_manager.update_state(game_id, state) + + # Check if offensive team is AI + if state.is_batting_team_ai(): + advance = await ai_opponent.decide_uncapped_trail_advance( + state, pending + ) + await self.submit_uncapped_trail_advance(game_id, advance) + return + + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_trail_advance", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "trail_runner_base": pending.trail_runner_base, + "trail_target_base": pending.trail_target_base, + "trail_runner_lineup_id": pending.trail_runner_lineup_id, + "hit_location": pending.hit_location, + }, + ) + + async def submit_uncapped_trail_advance( + self, game_id: UUID, advance: bool + ) -> None: + """ + Submit offensive decision: will trail runner attempt advance? + + If NO: roll d20 for lead runner only, transition to awaiting_uncapped_safe_out. + If YES: transition to awaiting_uncapped_throw_target. + """ + async with state_manager.game_lock(game_id): + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") + + pending = state.pending_uncapped_hit + if not pending: + raise ValueError("No pending uncapped hit") + + if state.decision_phase != "awaiting_uncapped_trail_advance": + raise ValueError( + f"Wrong phase: expected awaiting_uncapped_trail_advance, " + f"got {state.decision_phase}" + ) + + pending.trail_advance = advance + + if not advance: + # Trail declines → roll d20 for lead runner + d20 = dice_system.roll_d20( + game_id=game_id, + team_id=state.get_fielding_team_id(), + player_id=None, + ) + pending.speed_check_d20 = d20 + pending.speed_check_runner = "lead" + state.decision_phase = "awaiting_uncapped_safe_out" + state.pending_decision = "uncapped_safe_out" + state_manager.update_state(game_id, state) + + if state.is_batting_team_ai(): + result = await ai_opponent.decide_uncapped_safe_out(state, pending) + await self.submit_uncapped_safe_out(game_id, result) + return + + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_safe_out", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "d20_roll": d20, + "runner": "lead", + "runner_base": pending.lead_runner_base, + "target_base": pending.lead_target_base, + "runner_lineup_id": pending.lead_runner_lineup_id, + "hit_location": pending.hit_location, + }, + ) + else: + # Both runners advance → defense picks throw target + state.decision_phase = "awaiting_uncapped_throw_target" + state.pending_decision = "uncapped_throw_target" + state_manager.update_state(game_id, state) + + if state.is_fielding_team_ai(): + target = await ai_opponent.decide_uncapped_throw_target( + state, pending + ) + await self.submit_uncapped_throw_target(game_id, target) + return + + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_throw_target", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "lead_runner_base": pending.lead_runner_base, + "lead_target_base": pending.lead_target_base, + "lead_runner_lineup_id": pending.lead_runner_lineup_id, + "trail_runner_base": pending.trail_runner_base, + "trail_target_base": pending.trail_target_base, + "trail_runner_lineup_id": pending.trail_runner_lineup_id, + "hit_location": pending.hit_location, + }, + ) + + async def submit_uncapped_throw_target( + self, game_id: UUID, target: str + ) -> None: + """ + Submit defensive decision: throw for lead or trail runner? + + LEAD: trail auto-advances, roll d20 for lead → awaiting_uncapped_safe_out. + TRAIL: lead auto-advances, roll d20 for trail → awaiting_uncapped_safe_out. + """ + async with state_manager.game_lock(game_id): + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") + + pending = state.pending_uncapped_hit + if not pending: + raise ValueError("No pending uncapped hit") + + if state.decision_phase != "awaiting_uncapped_throw_target": + raise ValueError( + f"Wrong phase: expected awaiting_uncapped_throw_target, " + f"got {state.decision_phase}" + ) + + if target not in ("lead", "trail"): + raise ValueError(f"throw_target must be 'lead' or 'trail', got '{target}'") + + pending.throw_target = target + + # Roll d20 for the targeted runner + d20 = dice_system.roll_d20( + game_id=game_id, + team_id=state.get_fielding_team_id(), + player_id=None, + ) + pending.speed_check_d20 = d20 + pending.speed_check_runner = target + + state.decision_phase = "awaiting_uncapped_safe_out" + state.pending_decision = "uncapped_safe_out" + state_manager.update_state(game_id, state) + + # Determine which runner info to send + if target == "lead": + runner_base = pending.lead_runner_base + target_base = pending.lead_target_base + runner_lid = pending.lead_runner_lineup_id + else: + runner_base = pending.trail_runner_base + target_base = pending.trail_target_base + runner_lid = pending.trail_runner_lineup_id + + if state.is_batting_team_ai(): + result = await ai_opponent.decide_uncapped_safe_out(state, pending) + await self.submit_uncapped_safe_out(game_id, result) + return + + await self._emit_decision_required( + game_id=game_id, + state=state, + phase="awaiting_uncapped_safe_out", + timeout_seconds=self.DECISION_TIMEOUT, + data={ + "d20_roll": d20, + "runner": target, + "runner_base": runner_base, + "target_base": target_base, + "runner_lineup_id": runner_lid, + "hit_location": pending.hit_location, + }, + ) + + async def submit_uncapped_safe_out( + self, game_id: UUID, result: str + ) -> None: + """ + Submit offensive declaration: is the runner safe or out? + + Finalizes the uncapped hit play with the accumulated decisions. + + Args: + game_id: Game ID + result: "safe" or "out" + """ + async with state_manager.game_lock(game_id): + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") + + pending = state.pending_uncapped_hit + if not pending: + raise ValueError("No pending uncapped hit") + + if state.decision_phase != "awaiting_uncapped_safe_out": + raise ValueError( + f"Wrong phase: expected awaiting_uncapped_safe_out, " + f"got {state.decision_phase}" + ) + + if result not in ("safe", "out"): + raise ValueError(f"result must be 'safe' or 'out', got '{result}'") + + pending.speed_check_result = result + + ab_roll = state.pending_manual_roll + if not ab_roll: + raise ValueError("No pending manual roll found") + + play_result = self._build_uncapped_play_result(state, pending, ab_roll) + await self._finalize_uncapped_hit(state, pending, ab_roll, play_result) + + def _build_uncapped_fallback_result( + self, + state: GameState, + pending: PendingUncappedHit, + ab_roll: "AbRoll", + ) -> PlayResult: + """ + Build PlayResult when lead runner declines advance (standard advancement). + + Single: SINGLE_1 equivalent (R3 scores, R2→3rd, R1→2nd) + Double: DOUBLE_2 equivalent (all runners +2 bases) + """ + from app.core.play_resolver import PlayResolver, RunnerAdvancementData + + resolver = PlayResolver(league_id=state.league_id, auto_mode=False) + + if pending.hit_type == "single": + runners_advanced = resolver._advance_on_single_1(state) + outcome = PlayOutcome.SINGLE_UNCAPPED + batter_base = 1 + desc = "Single (uncapped) - runner holds" + else: + runners_advanced = resolver._advance_on_double_2(state) + outcome = PlayOutcome.DOUBLE_UNCAPPED + batter_base = 2 + desc = "Double (uncapped) - runner holds" + + runs_scored = sum(1 for adv in runners_advanced if adv.to_base == 4) + + return PlayResult( + outcome=outcome, + outs_recorded=0, + runs_scored=runs_scored, + batter_result=batter_base, + runners_advanced=runners_advanced, + description=desc, + ab_roll=ab_roll, + hit_location=pending.hit_location, + is_hit=True, + is_out=False, + is_walk=False, + ) + + def _build_uncapped_no_throw_result( + self, + state: GameState, + pending: PendingUncappedHit, + ab_roll: "AbRoll", + ) -> PlayResult: + """ + Build PlayResult when defense declines to throw. + + Lead runner advances safely. Trail runner and batter get standard advancement. + """ + from app.core.play_resolver import RunnerAdvancementData + + runners_advanced: list[RunnerAdvancementData] = [] + runs_scored = 0 + + # Auto-scoring runners (R3 on single, R3+R2 on double) + for from_base, to_base, lid in pending.auto_runners: + runners_advanced.append( + RunnerAdvancementData(from_base=from_base, to_base=to_base, lineup_id=lid) + ) + if to_base == 4: + runs_scored += 1 + + # Lead runner advances to target (safe, no throw) + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.lead_runner_base, + to_base=pending.lead_target_base, + lineup_id=pending.lead_runner_lineup_id, + ) + ) + if pending.lead_target_base == 4: + runs_scored += 1 + + # Trail runner gets standard advancement (one base advance from current) + if pending.trail_runner_base is not None and pending.trail_runner_base > 0: + # Trail is a runner on base, advance one base + trail_dest = pending.trail_runner_base + 1 + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.trail_runner_base, + to_base=trail_dest, + lineup_id=pending.trail_runner_lineup_id, + ) + ) + if trail_dest == 4: + runs_scored += 1 + + # Batter goes to minimum base + batter_base = pending.batter_base + + outcome = ( + PlayOutcome.SINGLE_UNCAPPED + if pending.hit_type == "single" + else PlayOutcome.DOUBLE_UNCAPPED + ) + + return PlayResult( + outcome=outcome, + outs_recorded=0, + runs_scored=runs_scored, + batter_result=batter_base, + runners_advanced=runners_advanced, + description=f"{'Single' if pending.hit_type == 'single' else 'Double'} (uncapped) - no throw, runner advances", + ab_roll=ab_roll, + hit_location=pending.hit_location, + is_hit=True, + is_out=False, + is_walk=False, + ) + + def _build_uncapped_play_result( + self, + state: GameState, + pending: PendingUncappedHit, + ab_roll: "AbRoll", + ) -> PlayResult: + """ + Build final PlayResult from accumulated uncapped hit decisions. + + Handles all combinations of lead/trail advance with safe/out outcomes. + """ + from app.core.play_resolver import RunnerAdvancementData + + runners_advanced: list[RunnerAdvancementData] = [] + runs_scored = 0 + outs_recorded = 0 + + # Auto-scoring runners always score + for from_base, to_base, lid in pending.auto_runners: + runners_advanced.append( + RunnerAdvancementData(from_base=from_base, to_base=to_base, lineup_id=lid) + ) + if to_base == 4: + runs_scored += 1 + + checked_runner = pending.speed_check_runner # "lead" or "trail" + is_safe = pending.speed_check_result == "safe" + + # Determine the non-targeted runner's outcome + if pending.throw_target is not None: + # Both runners attempted - defense chose a target + non_target = "trail" if pending.throw_target == "lead" else "lead" + + # Non-targeted runner auto-advances (safe) + if non_target == "lead": + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.lead_runner_base, + to_base=pending.lead_target_base, + lineup_id=pending.lead_runner_lineup_id, + ) + ) + if pending.lead_target_base == 4: + runs_scored += 1 + else: + # Trail auto-advances + if pending.trail_runner_base is not None and pending.trail_runner_base > 0: + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.trail_runner_base, + to_base=pending.trail_target_base, + lineup_id=pending.trail_runner_lineup_id, + ) + ) + if pending.trail_target_base == 4: + runs_scored += 1 + + # Targeted runner (or sole runner if no throw_target) + if checked_runner == "lead": + if is_safe: + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.lead_runner_base, + to_base=pending.lead_target_base, + lineup_id=pending.lead_runner_lineup_id, + ) + ) + if pending.lead_target_base == 4: + runs_scored += 1 + else: + # Runner is out + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.lead_runner_base, + to_base=0, + lineup_id=pending.lead_runner_lineup_id, + is_out=True, + ) + ) + outs_recorded += 1 + elif checked_runner == "trail": + if is_safe: + if pending.trail_runner_base is not None: + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.trail_runner_base, + to_base=pending.trail_target_base, + lineup_id=pending.trail_runner_lineup_id, + ) + ) + if pending.trail_target_base == 4: + runs_scored += 1 + else: + # Trail runner out + if pending.trail_runner_base is not None: + runners_advanced.append( + RunnerAdvancementData( + from_base=pending.trail_runner_base, + to_base=0, + lineup_id=pending.trail_runner_lineup_id, + is_out=True, + ) + ) + outs_recorded += 1 + + # If trail runner is R1 and R1 attempted advance, batter-runner + # auto-advances regardless of R1's outcome + batter_base = pending.batter_base + if ( + pending.trail_runner_base == 1 + and pending.trail_advance + and pending.trail_runner_base is not None + ): + # Batter auto-advances one extra base + batter_base = min(pending.batter_base + 1, 3) + elif ( + pending.trail_runner_base == 0 + and checked_runner == "trail" + ): + # Trail IS the batter - result already handled above + if is_safe and pending.trail_target_base: + batter_base = pending.trail_target_base + elif not is_safe: + batter_base = None # batter is out (handled by outs_recorded) + + outcome = ( + PlayOutcome.SINGLE_UNCAPPED + if pending.hit_type == "single" + else PlayOutcome.DOUBLE_UNCAPPED + ) + + # Build description + desc_parts = [ + f"{'Single' if pending.hit_type == 'single' else 'Double'} (uncapped) to {pending.hit_location}" + ] + if pending.speed_check_result: + runner_label = "lead" if checked_runner == "lead" else "trail" + desc_parts.append( + f"{runner_label} runner {'safe' if is_safe else 'out'} (d20={pending.speed_check_d20})" + ) + + return PlayResult( + outcome=outcome, + outs_recorded=outs_recorded, + runs_scored=runs_scored, + batter_result=batter_base, + runners_advanced=runners_advanced, + description=" - ".join(desc_parts), + ab_roll=ab_roll, + hit_location=pending.hit_location, + is_hit=True, + is_out=outs_recorded > 0, + is_walk=False, + ) + + async def _finalize_uncapped_hit( + self, + state: GameState, + pending: PendingUncappedHit, + ab_roll: "AbRoll", + result: PlayResult, + ) -> None: + """ + Finalize an uncapped hit play. + + Clears pending state, calls _finalize_play for DB write and state update. + """ + # Clear pending uncapped hit + state.pending_uncapped_hit = None + state.pending_decision = None + state.decision_phase = "idle" + + state_manager.update_state(state.game_id, state) + + log_suffix = f" (uncapped {pending.hit_type} to {pending.hit_location})" + await self._finalize_play(state, result, ab_roll, log_suffix) + + logger.info( + f"Uncapped {pending.hit_type} finalized for game {state.game_id}: " + f"{result.description}" + ) + # Placeholder methods for DECIDE workflow (to be implemented in step 9-10) async def submit_decide_advance(self, game_id: UUID, advance: bool) -> None: diff --git a/backend/app/core/play_resolver.py b/backend/app/core/play_resolver.py index e5817f3..97a81a3 100644 --- a/backend/app/core/play_resolver.py +++ b/backend/app/core/play_resolver.py @@ -520,8 +520,9 @@ class PlayResolver: f"{'3B' if state.on_third else ''}" ) - # TODO Phase 3: Implement uncapped hit decision tree - # For now, treat as SINGLE_1 + # Fallback path: used when GameEngine determines no interactive decision + # is needed (no eligible runners). Interactive workflow is handled by + # GameEngine.initiate_uncapped_hit() which intercepts before reaching here. runners_advanced = self._advance_on_single_1(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 @@ -533,7 +534,7 @@ class PlayResolver: runs_scored=runs_scored, batter_result=1, runners_advanced=runners_advanced, - description="Single to center (uncapped)", + description="Single (uncapped, no eligible runners)", ab_roll=ab_roll, is_hit=True, ) @@ -588,8 +589,9 @@ class PlayResolver: f"{'3B' if state.on_third else ''}" ) - # TODO Phase 3: Implement uncapped hit decision tree - # For now, treat as DOUBLE_2 + # Fallback path: used when GameEngine determines no interactive decision + # is needed (no R1). Interactive workflow is handled by + # GameEngine.initiate_uncapped_hit() which intercepts before reaching here. runners_advanced = self._advance_on_double_2(state) runs_scored = sum( 1 for adv in runners_advanced if adv.to_base == 4 @@ -601,7 +603,7 @@ class PlayResolver: runs_scored=runs_scored, batter_result=2, runners_advanced=runners_advanced, - description="Double (uncapped)", + description="Double (uncapped, no eligible runners)", ab_roll=ab_roll, is_hit=True, ) diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 6ca2125..cc8575c 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -450,6 +450,124 @@ class PendingXCheck(BaseModel): model_config = ConfigDict(frozen=False) # Allow mutation during workflow +# ============================================================================ +# PENDING UNCAPPED HIT STATE +# ============================================================================ + + +class PendingUncappedHit(BaseModel): + """ + Intermediate state for interactive uncapped hit resolution. + + Stores all uncapped hit workflow data as offensive/defensive players + make runner advancement decisions via WebSocket. + + Workflow: + 1. System identifies eligible runners → stores lead/trail info + 2. Offensive player decides if lead runner attempts advance + 3. Defensive player decides if they throw to base + 4. If trail runner exists, offensive decides trail advance + 5. If both advance, defensive picks throw target + 6. d20 speed check → offensive declares safe/out from card + 7. Play finalizes with accumulated decisions + + Attributes: + hit_type: "single" or "double" + hit_location: Outfield position (LF, CF, RF) + ab_roll_id: Reference to original AbRoll for audit trail + lead_runner_base: Base of lead runner (1 or 2) + lead_runner_lineup_id: Lineup ID of lead runner + lead_target_base: Base lead runner is attempting (3 or 4=HOME) + trail_runner_base: Base of trail runner (0=batter, 1=R1), None if no trail + trail_runner_lineup_id: Lineup ID of trail runner, None if no trail + trail_target_base: Base trail runner attempts, None if no trail + auto_runners: Auto-scoring runners [(from_base, to_base, lineup_id)] + batter_base: Minimum base batter reaches (1 for single, 2 for double) + batter_lineup_id: Batter's lineup ID + lead_advance: Offensive decision - does lead runner attempt advance? + defensive_throw: Defensive decision - throw to base? + trail_advance: Offensive decision - does trail runner attempt advance? + throw_target: Defensive decision - throw at "lead" or "trail"? + speed_check_d20: d20 roll for speed check + speed_check_runner: Which runner is being checked ("lead" or "trail") + speed_check_result: "safe" or "out" + """ + + # Hit context + hit_type: str # "single" or "double" + hit_location: str # "LF", "CF", or "RF" + ab_roll_id: str + + # Lead runner + lead_runner_base: int # 1 or 2 + lead_runner_lineup_id: int + lead_target_base: int # 3 or 4 (HOME) + + # Trail runner (None if no trail) + trail_runner_base: int | None = None # 0=batter, 1=R1 + trail_runner_lineup_id: int | None = None + trail_target_base: int | None = None + + # Auto-scoring runners (recorded before decision tree) + auto_runners: list[tuple[int, int, int]] = Field(default_factory=list) + # [(from_base, to_base, lineup_id), ...] e.g. R3 scores, R2 scores on double + + # Batter destination (minimum base) + batter_base: int # 1 for single, 2 for double + batter_lineup_id: int + + # Decisions (filled progressively) + lead_advance: bool | None = None + defensive_throw: bool | None = None + trail_advance: bool | None = None + throw_target: str | None = None # "lead" or "trail" + + # Speed check + speed_check_d20: int | None = Field(default=None, ge=1, le=20) + speed_check_runner: str | None = None # "lead" or "trail" + speed_check_result: str | None = None # "safe" or "out" + + @field_validator("hit_type") + @classmethod + def validate_hit_type(cls, v: str) -> str: + """Ensure hit_type is valid""" + valid = ["single", "double"] + if v not in valid: + raise ValueError(f"hit_type must be one of {valid}") + return v + + @field_validator("hit_location") + @classmethod + def validate_hit_location(cls, v: str) -> str: + """Ensure hit_location is an outfield position""" + valid = ["LF", "CF", "RF"] + if v not in valid: + raise ValueError(f"hit_location must be one of {valid}") + return v + + @field_validator("throw_target") + @classmethod + def validate_throw_target(cls, v: str | None) -> str | None: + """Ensure throw_target is valid""" + if v is not None: + valid = ["lead", "trail"] + if v not in valid: + raise ValueError(f"throw_target must be one of {valid}") + return v + + @field_validator("speed_check_result") + @classmethod + def validate_speed_check_result(cls, v: str | None) -> str | None: + """Ensure speed_check_result is valid""" + if v is not None: + valid = ["safe", "out"] + if v not in valid: + raise ValueError(f"speed_check_result must be one of {valid}") + return v + + model_config = ConfigDict(frozen=False) + + # ============================================================================ # GAME STATE # ============================================================================ @@ -570,6 +688,9 @@ class GameState(BaseModel): # Interactive x-check workflow pending_x_check: PendingXCheck | None = None + # Interactive uncapped hit workflow + pending_uncapped_hit: PendingUncappedHit | None = None + # Play tracking play_count: int = Field(default=0, ge=0) last_play_result: str | None = None @@ -614,6 +735,11 @@ class GameState(BaseModel): "decide_advance", "decide_throw", "decide_result", + "uncapped_lead_advance", + "uncapped_defensive_throw", + "uncapped_trail_advance", + "uncapped_throw_target", + "uncapped_safe_out", ] if v not in valid: raise ValueError(f"pending_decision must be one of {valid}") @@ -633,6 +759,11 @@ class GameState(BaseModel): "awaiting_decide_advance", "awaiting_decide_throw", "awaiting_decide_result", + "awaiting_uncapped_lead_advance", + "awaiting_uncapped_defensive_throw", + "awaiting_uncapped_trail_advance", + "awaiting_uncapped_throw_target", + "awaiting_uncapped_safe_out", ] if v not in valid: raise ValueError(f"decision_phase must be one of {valid}") @@ -961,5 +1092,6 @@ __all__ = [ "TeamLineupState", "DefensiveDecision", "OffensiveDecision", + "PendingUncappedHit", "GameState", ] diff --git a/backend/app/websocket/CLAUDE.md b/backend/app/websocket/CLAUDE.md index b0836e4..cc25f1b 100644 --- a/backend/app/websocket/CLAUDE.md +++ b/backend/app/websocket/CLAUDE.md @@ -23,7 +23,7 @@ Broadcast to All Players ``` app/websocket/ ├── connection_manager.py # Connection lifecycle & broadcasting -└── handlers.py # Event handler registration (15 handlers) +└── handlers.py # Event handler registration (20 handlers) ``` ## ConnectionManager @@ -43,7 +43,7 @@ await manager.broadcast_to_game(game_id, event, data) await manager.emit_to_user(sid, event, data) ``` -## Event Handlers (15 Total) +## Event Handlers (20 Total) ### Connection Events - `connect` - JWT authentication @@ -68,6 +68,13 @@ await manager.emit_to_user(sid, event, data) - `submit_pitching_change` - Pitcher substitution - `submit_defensive_replacement` - Field substitution +### Uncapped Hit Decisions +- `submit_uncapped_lead_advance` - Lead runner advance choice (offensive) +- `submit_uncapped_defensive_throw` - Throw to base choice (defensive) +- `submit_uncapped_trail_advance` - Trail runner advance choice (offensive) +- `submit_uncapped_throw_target` - Throw at lead or trail (defensive) +- `submit_uncapped_safe_out` - Declare safe or out from card (offensive) + ### Lineup - `get_lineup` - Get team lineup @@ -116,4 +123,4 @@ await manager.emit_to_user(sid, "error", {"message": str(e)}) --- -**Handlers**: 15/15 implemented | **Updated**: 2025-01-19 +**Handlers**: 20/20 implemented | **Updated**: 2026-02-11 diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 7a5cbe5..43f8714 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -2075,3 +2075,295 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user( sid, "error", {"message": "DECIDE workflow not yet implemented"} ) + + # ============================================================================ + # INTERACTIVE UNCAPPED HIT HANDLERS + # ============================================================================ + + @sio.event + async def submit_uncapped_lead_advance(sid, data): + """ + Submit offensive decision: will lead runner attempt advance on uncapped hit? + + Event data: + game_id: UUID of the game + advance: bool - True if runner attempts advance + + Emits: + decision_required: Next phase if more decisions needed + game_state_update: Broadcast when play finalizes + error: To requester if validation fails + """ + await manager.update_activity(sid) + + if not await rate_limiter.check_websocket_limit(sid): + await manager.emit_to_user( + sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"} + ) + return + + try: + game_id_str = data.get("game_id") + if not game_id_str: + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) + return + + try: + game_id = UUID(game_id_str) + except (ValueError, AttributeError): + await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"}) + return + + advance = data.get("advance") + if advance is None or not isinstance(advance, bool): + await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"}) + return + + await game_engine.submit_uncapped_lead_advance(game_id, advance) + + # Broadcast updated state + state = state_manager.get_state(game_id) + if state: + await manager.broadcast_to_game( + str(game_id), "game_state_update", state.model_dump(mode="json") + ) + + except ValueError as e: + logger.warning(f"Uncapped lead advance validation failed: {e}") + await manager.emit_to_user(sid, "error", {"message": str(e)}) + except DatabaseError as e: + logger.error(f"Database error in submit_uncapped_lead_advance: {e}") + await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"}) + except (TypeError, AttributeError) as e: + logger.warning(f"Invalid data in submit_uncapped_lead_advance: {e}") + await manager.emit_to_user(sid, "error", {"message": "Invalid request"}) + + @sio.event + async def submit_uncapped_defensive_throw(sid, data): + """ + Submit defensive decision: will you throw to the base? + + Event data: + game_id: UUID of the game + will_throw: bool - True if defense throws + + Emits: + decision_required: Next phase if more decisions needed + game_state_update: Broadcast when play finalizes + error: To requester if validation fails + """ + await manager.update_activity(sid) + + if not await rate_limiter.check_websocket_limit(sid): + await manager.emit_to_user( + sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"} + ) + return + + try: + game_id_str = data.get("game_id") + if not game_id_str: + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) + return + + try: + game_id = UUID(game_id_str) + except (ValueError, AttributeError): + await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"}) + return + + will_throw = data.get("will_throw") + if will_throw is None or not isinstance(will_throw, bool): + await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'will_throw' (bool)"}) + return + + await game_engine.submit_uncapped_defensive_throw(game_id, will_throw) + + state = state_manager.get_state(game_id) + if state: + await manager.broadcast_to_game( + str(game_id), "game_state_update", state.model_dump(mode="json") + ) + + except ValueError as e: + logger.warning(f"Uncapped defensive throw validation failed: {e}") + await manager.emit_to_user(sid, "error", {"message": str(e)}) + except DatabaseError as e: + logger.error(f"Database error in submit_uncapped_defensive_throw: {e}") + await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"}) + except (TypeError, AttributeError) as e: + logger.warning(f"Invalid data in submit_uncapped_defensive_throw: {e}") + await manager.emit_to_user(sid, "error", {"message": "Invalid request"}) + + @sio.event + async def submit_uncapped_trail_advance(sid, data): + """ + Submit offensive decision: will trail runner attempt advance? + + Event data: + game_id: UUID of the game + advance: bool - True if trail runner attempts advance + + Emits: + decision_required: Next phase if more decisions needed + game_state_update: Broadcast when play finalizes + error: To requester if validation fails + """ + await manager.update_activity(sid) + + if not await rate_limiter.check_websocket_limit(sid): + await manager.emit_to_user( + sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"} + ) + return + + try: + game_id_str = data.get("game_id") + if not game_id_str: + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) + return + + try: + game_id = UUID(game_id_str) + except (ValueError, AttributeError): + await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"}) + return + + advance = data.get("advance") + if advance is None or not isinstance(advance, bool): + await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"}) + return + + await game_engine.submit_uncapped_trail_advance(game_id, advance) + + state = state_manager.get_state(game_id) + if state: + await manager.broadcast_to_game( + str(game_id), "game_state_update", state.model_dump(mode="json") + ) + + except ValueError as e: + logger.warning(f"Uncapped trail advance validation failed: {e}") + await manager.emit_to_user(sid, "error", {"message": str(e)}) + except DatabaseError as e: + logger.error(f"Database error in submit_uncapped_trail_advance: {e}") + await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"}) + except (TypeError, AttributeError) as e: + logger.warning(f"Invalid data in submit_uncapped_trail_advance: {e}") + await manager.emit_to_user(sid, "error", {"message": "Invalid request"}) + + @sio.event + async def submit_uncapped_throw_target(sid, data): + """ + Submit defensive decision: throw for lead or trail runner? + + Event data: + game_id: UUID of the game + target: str - "lead" or "trail" + + Emits: + decision_required: awaiting_uncapped_safe_out with d20 roll + error: To requester if validation fails + """ + await manager.update_activity(sid) + + if not await rate_limiter.check_websocket_limit(sid): + await manager.emit_to_user( + sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"} + ) + return + + try: + game_id_str = data.get("game_id") + if not game_id_str: + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) + return + + try: + game_id = UUID(game_id_str) + except (ValueError, AttributeError): + await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"}) + return + + target = data.get("target") + if target not in ("lead", "trail"): + await manager.emit_to_user( + sid, "error", {"message": "target must be 'lead' or 'trail'"} + ) + return + + await game_engine.submit_uncapped_throw_target(game_id, target) + + state = state_manager.get_state(game_id) + if state: + await manager.broadcast_to_game( + str(game_id), "game_state_update", state.model_dump(mode="json") + ) + + except ValueError as e: + logger.warning(f"Uncapped throw target validation failed: {e}") + await manager.emit_to_user(sid, "error", {"message": str(e)}) + except DatabaseError as e: + logger.error(f"Database error in submit_uncapped_throw_target: {e}") + await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"}) + except (TypeError, AttributeError) as e: + logger.warning(f"Invalid data in submit_uncapped_throw_target: {e}") + await manager.emit_to_user(sid, "error", {"message": "Invalid request"}) + + @sio.event + async def submit_uncapped_safe_out(sid, data): + """ + Submit offensive declaration: is the runner safe or out? + + Event data: + game_id: UUID of the game + result: str - "safe" or "out" + + Emits: + game_state_update: Broadcast when play finalizes + error: To requester if validation fails + """ + await manager.update_activity(sid) + + if not await rate_limiter.check_websocket_limit(sid): + await manager.emit_to_user( + sid, "error", {"message": "Rate limited.", "code": "RATE_LIMITED"} + ) + return + + try: + game_id_str = data.get("game_id") + if not game_id_str: + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) + return + + try: + game_id = UUID(game_id_str) + except (ValueError, AttributeError): + await manager.emit_to_user(sid, "error", {"message": "Invalid game_id format"}) + return + + result = data.get("result") + if result not in ("safe", "out"): + await manager.emit_to_user( + sid, "error", {"message": "result must be 'safe' or 'out'"} + ) + return + + await game_engine.submit_uncapped_safe_out(game_id, result) + + state = state_manager.get_state(game_id) + if state: + await manager.broadcast_to_game( + str(game_id), "game_state_update", state.model_dump(mode="json") + ) + + except ValueError as e: + logger.warning(f"Uncapped safe/out validation failed: {e}") + await manager.emit_to_user(sid, "error", {"message": str(e)}) + except DatabaseError as e: + logger.error(f"Database error in submit_uncapped_safe_out: {e}") + await manager.emit_to_user(sid, "error", {"message": "Database error - please retry"}) + except (TypeError, AttributeError) as e: + logger.warning(f"Invalid data in submit_uncapped_safe_out: {e}") + await manager.emit_to_user(sid, "error", {"message": "Invalid request"}) diff --git a/backend/tests/CLAUDE.md b/backend/tests/CLAUDE.md index 7b7dfc2..f64eb00 100644 --- a/backend/tests/CLAUDE.md +++ b/backend/tests/CLAUDE.md @@ -55,7 +55,7 @@ See `backend/CLAUDE.md` → "Testing Policy" section for full details. ### Current Test Baseline **Must maintain or improve:** -- ✅ Unit tests: **979/979 passing (100%)** +- ✅ Unit tests: **2481/2481 passing (100%)** - ✅ Integration tests: **32/32 passing (100%)** - ⏱️ Unit execution: **~4 seconds** - ⏱️ Integration execution: **~5 seconds** @@ -320,10 +320,10 @@ All major test infrastructure issues have been resolved. The test suite is now s ## Test Coverage -**Current Status** (as of 2025-11-27): -- ✅ **979 unit tests passing** (100%) +**Current Status** (as of 2026-02-11): +- ✅ **2481 unit tests passing** (100%) - ✅ **32 integration tests passing** (100%) -- **Total: 1,011 tests passing** +- **Total: 2,513 tests passing** **Coverage by Module**: ``` @@ -333,7 +333,7 @@ app/core/state_manager.py ✅ Well covered app/core/dice.py ✅ Well covered app/models/ ✅ Well covered app/database/operations.py ✅ 32 integration tests (session injection pattern) -app/websocket/handlers.py ✅ 148 WebSocket handler tests +app/websocket/handlers.py ✅ 171 WebSocket handler tests app/middleware/ ✅ Rate limiting, exceptions tested ``` @@ -520,4 +520,4 @@ Transactions: db_ops = DatabaseOperations(session) → Multiple ops, single comm --- -**Summary**: All 1,011 tests passing (979 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup. +**Summary**: All 2,513 tests passing (2481 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup. diff --git a/backend/tests/unit/core/test_uncapped_hit_workflow.py b/backend/tests/unit/core/test_uncapped_hit_workflow.py new file mode 100644 index 0000000..87a08b6 --- /dev/null +++ b/backend/tests/unit/core/test_uncapped_hit_workflow.py @@ -0,0 +1,1115 @@ +""" +Tests: Uncapped Hit Interactive Workflow + +Tests the full decision tree for SINGLE_UNCAPPED and DOUBLE_UNCAPPED outcomes, +verifying each decision branch (lead advance, defensive throw, trail advance, +throw target, safe/out) produces the correct PlayResult. + +These tests exercise the GameEngine methods directly (not via WebSocket), +mocking the state_manager and dice_system dependencies. + +Decision flow summary: + 1. initiate_uncapped_hit() → identifies lead/trail, creates PendingUncappedHit + 2. submit_uncapped_lead_advance(advance=bool) + - False → fallback (SINGLE_1/DOUBLE_2), finalize + - True → awaiting_uncapped_defensive_throw + 3. submit_uncapped_defensive_throw(will_throw=bool) + - False → lead runner safe, finalize + - True + trail → awaiting_uncapped_trail_advance + - True + no trail → roll d20, awaiting_uncapped_safe_out + 4. submit_uncapped_trail_advance(advance=bool) + - False → roll d20 for lead, awaiting_uncapped_safe_out + - True → awaiting_uncapped_throw_target + 5. submit_uncapped_throw_target(target="lead"|"trail") + - Roll d20 for target, awaiting_uncapped_safe_out + 6. submit_uncapped_safe_out(result="safe"|"out") → finalize + +Author: Claude +Date: 2025-02-11 +""" + +import pytest +from contextlib import asynccontextmanager +from uuid import uuid4 +from unittest.mock import AsyncMock, MagicMock, patch + +import pendulum + +from app.config import PlayOutcome +from app.core.game_engine import GameEngine +from app.core.roll_types import AbRoll, RollType +from app.models.game_models import ( + GameState, + LineupPlayerState, + PendingUncappedHit, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +BATTER_LID = 1 +R1_LID = 10 +R2_LID = 20 +R3_LID = 30 + + +def make_player(lineup_id: int, batting_order: int = 1) -> LineupPlayerState: + """Create a LineupPlayerState for testing.""" + return LineupPlayerState( + lineup_id=lineup_id, + card_id=lineup_id * 100, + position="CF", + batting_order=batting_order, + ) + + +def make_ab_roll(game_id=None) -> AbRoll: + """Create a mock AbRoll for testing.""" + return AbRoll( + roll_type=RollType.AB, + roll_id="test_uncapped_workflow", + timestamp=pendulum.now("UTC"), + league_id="sba", + game_id=game_id, + d6_one=3, + d6_two_a=2, + d6_two_b=4, + chaos_d20=10, + resolution_d20=10, + ) + + +def make_state( + on_first=False, on_second=False, on_third=False, outs=0 +) -> GameState: + """Create a GameState with specific runner configuration.""" + game_id = uuid4() + state = GameState( + game_id=game_id, + league_id="sba", + home_team_id=1, + away_team_id=2, + outs=outs, + status="active", + current_batter=make_player(BATTER_LID, batting_order=1), + on_first=make_player(R1_LID, batting_order=2) if on_first else None, + on_second=make_player(R2_LID, batting_order=3) if on_second else None, + on_third=make_player(R3_LID, batting_order=4) if on_third else None, + ) + state.current_on_base_code = state.calculate_on_base_code() + return state + + +class MockStateManager: + """Minimal mock state manager for workflow tests.""" + + def __init__(self, state: GameState): + self._state = state + + def get_state(self, game_id): + return self._state + + def update_state(self, game_id, state): + self._state = state + + @asynccontextmanager + async def game_lock(self, game_id, timeout=30.0): + yield + + +@pytest.fixture +def engine(): + """Create a GameEngine with connection manager disabled.""" + eng = GameEngine() + eng._connection_manager = MagicMock() + eng._connection_manager.broadcast_to_game = AsyncMock() + return eng + + +# ============================================================================= +# Helper: run full workflow +# ============================================================================= + + +async def run_uncapped_workflow( + engine: GameEngine, + state: GameState, + outcome: PlayOutcome, + hit_location: str = "CF", + lead_advance: bool = True, + defensive_throw: bool = True, + trail_advance: bool | None = None, + throw_target: str | None = None, + safe_or_out: str = "safe", + d20_value: int = 10, +) -> tuple[GameState, PendingUncappedHit | None]: + """ + Run the uncapped hit workflow through the decision tree and return final state. + + Steps through each decision phase based on the provided choices. + Returns the state at the point where it finalized (or the last phase). + """ + game_id = state.game_id + ab_roll = make_ab_roll(game_id) + + # Store the ab_roll as pending_manual_roll (engine expects this) + state.pending_manual_roll = ab_roll + + mock_sm = MockStateManager(state) + mock_dice = MagicMock() + mock_dice.roll_d20 = MagicMock(return_value=d20_value) + + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.dice_system", mock_dice), \ + patch("app.core.game_engine.game_validator") as mock_val, \ + patch.object(engine, "_finalize_play", new_callable=AsyncMock): + + mock_val.validate_game_active = MagicMock() + + # Step 1: Initiate + await engine.initiate_uncapped_hit(game_id, outcome, hit_location, ab_roll) + + current_state = mock_sm.get_state(game_id) + + # Step 2: Lead advance + if current_state.decision_phase == "awaiting_uncapped_lead_advance": + await engine.submit_uncapped_lead_advance(game_id, lead_advance) + current_state = mock_sm.get_state(game_id) + + if not lead_advance: + return current_state, current_state.pending_uncapped_hit + + # Step 3: Defensive throw + if current_state.decision_phase == "awaiting_uncapped_defensive_throw": + await engine.submit_uncapped_defensive_throw(game_id, defensive_throw) + current_state = mock_sm.get_state(game_id) + + if not defensive_throw: + return current_state, current_state.pending_uncapped_hit + + # Step 4: Trail advance (if applicable) + if current_state.decision_phase == "awaiting_uncapped_trail_advance": + advance = trail_advance if trail_advance is not None else False + await engine.submit_uncapped_trail_advance(game_id, advance) + current_state = mock_sm.get_state(game_id) + + # Step 5: Throw target (if both runners advance) + if current_state.decision_phase == "awaiting_uncapped_throw_target": + target = throw_target or "lead" + await engine.submit_uncapped_throw_target(game_id, target) + current_state = mock_sm.get_state(game_id) + + # Step 6: Safe/out + if current_state.decision_phase == "awaiting_uncapped_safe_out": + await engine.submit_uncapped_safe_out(game_id, safe_or_out) + current_state = mock_sm.get_state(game_id) + + return current_state, current_state.pending_uncapped_hit + + +# ============================================================================= +# Tests: _uncapped_needs_decision +# ============================================================================= + + +class TestUncappedNeedsDecision: + """Verify _uncapped_needs_decision correctly identifies when interactive flow is needed.""" + + def test_single_uncapped_needs_decision_r1(self, engine): + """SINGLE_UNCAPPED with R1: needs decision (R1 is lead runner attempting 3rd).""" + state = make_state(on_first=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.SINGLE_UNCAPPED) is True + + def test_single_uncapped_needs_decision_r2(self, engine): + """SINGLE_UNCAPPED with R2: needs decision (R2 is lead runner attempting HOME).""" + state = make_state(on_second=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.SINGLE_UNCAPPED) is True + + def test_single_uncapped_needs_decision_r1_r2(self, engine): + """SINGLE_UNCAPPED with R1+R2: needs decision (R2 lead, R1 trail).""" + state = make_state(on_first=True, on_second=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.SINGLE_UNCAPPED) is True + + def test_single_uncapped_no_decision_empty(self, engine): + """SINGLE_UNCAPPED with empty bases: no decision needed (fallback).""" + state = make_state() + assert engine._uncapped_needs_decision(state, PlayOutcome.SINGLE_UNCAPPED) is False + + def test_single_uncapped_no_decision_r3_only(self, engine): + """SINGLE_UNCAPPED with R3 only: no decision needed (R3 auto-scores, fallback).""" + state = make_state(on_third=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.SINGLE_UNCAPPED) is False + + def test_double_uncapped_needs_decision_r1(self, engine): + """DOUBLE_UNCAPPED with R1: needs decision (R1 is lead runner attempting HOME).""" + state = make_state(on_first=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.DOUBLE_UNCAPPED) is True + + def test_double_uncapped_needs_decision_r1_r2(self, engine): + """DOUBLE_UNCAPPED with R1+R2: needs decision (R1 lead, batter trail).""" + state = make_state(on_first=True, on_second=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.DOUBLE_UNCAPPED) is True + + def test_double_uncapped_no_decision_empty(self, engine): + """DOUBLE_UNCAPPED with empty bases: no decision needed (fallback).""" + state = make_state() + assert engine._uncapped_needs_decision(state, PlayOutcome.DOUBLE_UNCAPPED) is False + + def test_double_uncapped_no_decision_r2_only(self, engine): + """DOUBLE_UNCAPPED with R2 only: no decision needed (R2 auto-scores, fallback).""" + state = make_state(on_second=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.DOUBLE_UNCAPPED) is False + + def test_double_uncapped_no_decision_r3_only(self, engine): + """DOUBLE_UNCAPPED with R3 only: no decision needed (R3 auto-scores, fallback).""" + state = make_state(on_third=True) + assert engine._uncapped_needs_decision(state, PlayOutcome.DOUBLE_UNCAPPED) is False + + +# ============================================================================= +# Tests: initiate_uncapped_hit — state setup +# ============================================================================= + + +class TestInitiateUncappedHit: + """Verify initiate_uncapped_hit correctly identifies runners and sets pending state.""" + + @pytest.mark.asyncio + async def test_single_r1_only_identifies_lead_trail(self, engine): + """ + SINGLE_UNCAPPED with R1 only: + Lead = R1 attempting 3rd, Trail = batter attempting 2nd. + """ + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.SINGLE_UNCAPPED, "LF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert pending is not None + assert pending.hit_type == "single" + assert pending.lead_runner_base == 1 + assert pending.lead_runner_lineup_id == R1_LID + assert pending.lead_target_base == 3 + assert pending.trail_runner_base == 0 # batter + assert pending.trail_runner_lineup_id == BATTER_LID + assert pending.trail_target_base == 2 + assert pending.auto_runners == [] + + @pytest.mark.asyncio + async def test_single_r2_only_identifies_lead_trail(self, engine): + """ + SINGLE_UNCAPPED with R2 only: + Lead = R2 attempting HOME, Trail = batter attempting 2nd. + """ + state = make_state(on_second=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.SINGLE_UNCAPPED, "CF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert pending.lead_runner_base == 2 + assert pending.lead_runner_lineup_id == R2_LID + assert pending.lead_target_base == 4 # HOME + assert pending.trail_runner_base == 0 # batter + assert pending.trail_runner_lineup_id == BATTER_LID + assert pending.trail_target_base == 2 + + @pytest.mark.asyncio + async def test_single_r1_r2_identifies_lead_trail(self, engine): + """ + SINGLE_UNCAPPED with R1+R2: + Lead = R2 attempting HOME, Trail = R1 attempting 3rd. + """ + state = make_state(on_first=True, on_second=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.SINGLE_UNCAPPED, "RF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert pending.lead_runner_base == 2 + assert pending.lead_runner_lineup_id == R2_LID + assert pending.lead_target_base == 4 + assert pending.trail_runner_base == 1 # R1 + assert pending.trail_runner_lineup_id == R1_LID + assert pending.trail_target_base == 3 + + @pytest.mark.asyncio + async def test_single_r3_r2_auto_scores_r3(self, engine): + """ + SINGLE_UNCAPPED with R2+R3: + R3 auto-scores, Lead = R2 attempting HOME, Trail = batter. + """ + state = make_state(on_second=True, on_third=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.SINGLE_UNCAPPED, "CF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert (3, 4, R3_LID) in pending.auto_runners + assert pending.lead_runner_base == 2 + assert pending.lead_target_base == 4 + + @pytest.mark.asyncio + async def test_single_loaded_auto_scores_r3(self, engine): + """ + SINGLE_UNCAPPED with loaded bases: + R3 auto-scores, Lead = R2 attempting HOME, Trail = R1 attempting 3rd. + """ + state = make_state(on_first=True, on_second=True, on_third=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.SINGLE_UNCAPPED, "LF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert (3, 4, R3_LID) in pending.auto_runners + assert pending.lead_runner_base == 2 + assert pending.lead_runner_lineup_id == R2_LID + assert pending.trail_runner_base == 1 + assert pending.trail_runner_lineup_id == R1_LID + + @pytest.mark.asyncio + async def test_double_r1_only_identifies_lead_trail(self, engine): + """ + DOUBLE_UNCAPPED with R1 only: + Lead = R1 attempting HOME, Trail = batter attempting 3rd. + """ + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.DOUBLE_UNCAPPED, "CF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert pending.hit_type == "double" + assert pending.lead_runner_base == 1 + assert pending.lead_runner_lineup_id == R1_LID + assert pending.lead_target_base == 4 # HOME + assert pending.trail_runner_base == 0 # batter + assert pending.trail_runner_lineup_id == BATTER_LID + assert pending.trail_target_base == 3 + assert pending.batter_base == 2 + + @pytest.mark.asyncio + async def test_double_r1_r2_auto_scores_r2(self, engine): + """ + DOUBLE_UNCAPPED with R1+R2: + R2 auto-scores, Lead = R1 attempting HOME, Trail = batter. + """ + state = make_state(on_first=True, on_second=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.DOUBLE_UNCAPPED, "RF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert (2, 4, R2_LID) in pending.auto_runners + assert pending.lead_runner_base == 1 + assert pending.lead_target_base == 4 + + @pytest.mark.asyncio + async def test_double_loaded_auto_scores_r3_r2(self, engine): + """ + DOUBLE_UNCAPPED with loaded bases: + R3+R2 auto-score, Lead = R1 attempting HOME, Trail = batter. + """ + state = make_state(on_first=True, on_second=True, on_third=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.DOUBLE_UNCAPPED, "LF", state.pending_manual_roll + ) + + pending = mock_sm.get_state(state.game_id).pending_uncapped_hit + assert (3, 4, R3_LID) in pending.auto_runners + assert (2, 4, R2_LID) in pending.auto_runners + assert len(pending.auto_runners) == 2 + + @pytest.mark.asyncio + async def test_decision_phase_set_correctly(self, engine): + """After initiation, decision_phase should be awaiting_uncapped_lead_advance.""" + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm), \ + patch("app.core.game_engine.game_validator"): + await engine.initiate_uncapped_hit( + state.game_id, PlayOutcome.SINGLE_UNCAPPED, "CF", state.pending_manual_roll + ) + + final_state = mock_sm.get_state(state.game_id) + assert final_state.decision_phase == "awaiting_uncapped_lead_advance" + assert final_state.pending_decision == "uncapped_lead_advance" + + +# ============================================================================= +# Tests: Lead runner declines advance → fallback +# ============================================================================= + + +class TestLeadRunnerDeclines: + """When lead runner declines advance, play finalizes with standard advancement.""" + + @pytest.mark.asyncio + async def test_single_r1_lead_declines(self, engine): + """ + SINGLE_UNCAPPED, R1 only, lead (R1) declines: + Fallback to SINGLE_1 → R1→2nd, batter→1st. + """ + state = make_state(on_first=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=False, + ) + # Should have finalized (pending cleared) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r2_lead_declines(self, engine): + """ + SINGLE_UNCAPPED, R2 only, lead (R2) declines: + Fallback to SINGLE_1 → R2→3rd, batter→1st. + """ + state = make_state(on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=False, + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_double_r1_lead_declines(self, engine): + """ + DOUBLE_UNCAPPED, R1 only, lead (R1) declines: + Fallback to DOUBLE_2 → R1→3rd, batter→2nd. + """ + state = make_state(on_first=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.DOUBLE_UNCAPPED, + lead_advance=False, + ) + assert final_state.pending_uncapped_hit is None + + +# ============================================================================= +# Tests: Defense declines throw → lead runner safe +# ============================================================================= + + +class TestDefenseDeclines: + """When defense declines throw, lead runner advances safely.""" + + @pytest.mark.asyncio + async def test_single_r1_no_throw(self, engine): + """ + SINGLE_UNCAPPED, R1, lead advances, defense doesn't throw: + R1 advances to 3rd safely, batter to 1st. + """ + state = make_state(on_first=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=False, + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r2_no_throw(self, engine): + """ + SINGLE_UNCAPPED, R2, lead advances, defense doesn't throw: + R2 scores safely, batter to 1st. + """ + state = make_state(on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=False, + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_double_r1_no_throw_r1_scores(self, engine): + """ + DOUBLE_UNCAPPED, R1, lead advances, defense doesn't throw: + R1 scores safely, batter to 2nd. + """ + state = make_state(on_first=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.DOUBLE_UNCAPPED, + lead_advance=True, + defensive_throw=False, + ) + assert final_state.pending_uncapped_hit is None + + +# ============================================================================= +# Tests: Defense throws, no trail runner → speed check on lead +# ============================================================================= + + +class TestDefenseThrowsNoTrail: + """When defense throws and there's no trail runner, speed check on lead runner.""" + + @pytest.mark.asyncio + async def test_single_r2_throw_lead_safe(self, engine): + """ + SINGLE_UNCAPPED, R2 only (trail=batter), defense throws, lead safe: + R2 scores, batter advances to 2nd (trail batter standard advance). + Wait — with R2 only, trail IS the batter (base 0). + The trail_runner_base=0 means trail is the batter, not a baserunner. + Defense throws → no trail (trail_runner_base == 0, not > 0) → speed check on lead. + """ + # R2 as lead, batter as trail (trail_runner_base=0) + # Defense throws → trail_runner_base is 0 (batter), so has_trail is True + # Actually let's check: pending.trail_runner_base is not None for R2 only case + # In initiate: R2 exists, no R1 → trail_base=0 (batter), trail_lid=BATTER_LID + # In submit_defensive_throw: has_trail = pending.trail_runner_base is not None + # trail_runner_base=0 is not None, so has_trail=True + # → goes to awaiting_uncapped_trail_advance, not directly to safe_out + # This is correct! The trail runner IS the batter in this case. + state = make_state(on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=False, # batter (trail) doesn't attempt extra advance + safe_or_out="safe", + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r2_throw_lead_out(self, engine): + """ + SINGLE_UNCAPPED, R2 only, defense throws, trail declines, lead OUT: + R2 is thrown out. Batter still goes to 1st. + """ + state = make_state(on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=False, + safe_or_out="out", + ) + assert final_state.pending_uncapped_hit is None + + +# ============================================================================= +# Tests: Trail runner decisions +# ============================================================================= + + +class TestTrailRunnerDecisions: + """Test trail runner advance/decline paths.""" + + @pytest.mark.asyncio + async def test_single_r1_r2_trail_declines_lead_safe(self, engine): + """ + SINGLE_UNCAPPED, R1+R2: + Lead (R2) advances, defense throws, trail (R1) declines. + Speed check on lead → safe: R2 scores, R1 holds at 2nd. + + Wait — when trail (R1) declines, R1 doesn't attempt extra advance. + In the _build_uncapped_play_result, if trail doesn't advance, we only + check the lead runner. R1 stays at current position. + """ + state = make_state(on_first=True, on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=False, + safe_or_out="safe", + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r1_r2_trail_declines_lead_out(self, engine): + """ + SINGLE_UNCAPPED, R1+R2: + Lead (R2) advances, defense throws, trail (R1) declines. + Speed check on lead → OUT: R2 is out, 1 out recorded. + """ + state = make_state(on_first=True, on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=False, + safe_or_out="out", + ) + assert final_state.pending_uncapped_hit is None + + +# ============================================================================= +# Tests: Both runners advance → throw target +# ============================================================================= + + +class TestThrowTarget: + """Test throw target selection when both lead and trail runners advance.""" + + @pytest.mark.asyncio + async def test_single_r1_r2_throw_at_lead_safe(self, engine): + """ + SINGLE_UNCAPPED, R1+R2, both advance, defense throws at lead (R2): + Trail (R1) auto-advances to 3rd. + Speed check on R2 → safe: R2 scores. + """ + state = make_state(on_first=True, on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=True, + throw_target="lead", + safe_or_out="safe", + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r1_r2_throw_at_lead_out(self, engine): + """ + SINGLE_UNCAPPED, R1+R2, both advance, defense throws at lead (R2): + Trail (R1) auto-advances to 3rd. + Speed check on R2 → OUT: R2 is out, 1 out recorded. + """ + state = make_state(on_first=True, on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=True, + throw_target="lead", + safe_or_out="out", + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r1_r2_throw_at_trail_safe(self, engine): + """ + SINGLE_UNCAPPED, R1+R2, both advance, defense throws at trail (R1): + Lead (R2) auto-advances to HOME (scores). + Speed check on R1 → safe: R1 advances to 3rd. + """ + state = make_state(on_first=True, on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=True, + throw_target="trail", + safe_or_out="safe", + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_single_r1_r2_throw_at_trail_out(self, engine): + """ + SINGLE_UNCAPPED, R1+R2, both advance, defense throws at trail (R1): + Lead (R2) auto-advances to HOME (scores). + Speed check on R1 → OUT: R1 is out, 1 out recorded. + """ + state = make_state(on_first=True, on_second=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.SINGLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=True, + throw_target="trail", + safe_or_out="out", + ) + assert final_state.pending_uncapped_hit is None + + @pytest.mark.asyncio + async def test_double_loaded_throw_at_lead_safe(self, engine): + """ + DOUBLE_UNCAPPED, loaded bases: + R3+R2 auto-score, Lead = R1 attempting HOME, Trail = batter attempting 3rd. + Both advance, defense throws at lead (R1) → safe: R1 scores, batter to 3rd. + """ + state = make_state(on_first=True, on_second=True, on_third=True) + final_state, pending = await run_uncapped_workflow( + engine, state, + outcome=PlayOutcome.DOUBLE_UNCAPPED, + lead_advance=True, + defensive_throw=True, + trail_advance=True, + throw_target="lead", + safe_or_out="safe", + ) + assert final_state.pending_uncapped_hit is None + + +# ============================================================================= +# Tests: Phase validation errors +# ============================================================================= + + +class TestPhaseValidation: + """Verify that submitting decisions in wrong phase raises errors.""" + + @pytest.mark.asyncio + async def test_lead_advance_wrong_phase(self, engine): + """Submitting lead advance when not in awaiting_uncapped_lead_advance raises ValueError.""" + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + state.decision_phase = "awaiting_defensive" # Wrong phase + state.pending_uncapped_hit = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id="test", + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=3, batter_base=1, batter_lineup_id=BATTER_LID, + ) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm): + with pytest.raises(ValueError, match="Wrong phase"): + await engine.submit_uncapped_lead_advance(state.game_id, True) + + @pytest.mark.asyncio + async def test_defensive_throw_wrong_phase(self, engine): + """Submitting defensive throw when not in awaiting_uncapped_defensive_throw raises ValueError.""" + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + state.decision_phase = "awaiting_uncapped_lead_advance" # Wrong phase + state.pending_uncapped_hit = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id="test", + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=3, batter_base=1, batter_lineup_id=BATTER_LID, + ) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm): + with pytest.raises(ValueError, match="Wrong phase"): + await engine.submit_uncapped_defensive_throw(state.game_id, True) + + @pytest.mark.asyncio + async def test_no_pending_uncapped_hit(self, engine): + """Submitting any uncapped decision without pending state raises ValueError.""" + state = make_state(on_first=True) + state.decision_phase = "awaiting_uncapped_lead_advance" + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm): + with pytest.raises(ValueError, match="No pending uncapped hit"): + await engine.submit_uncapped_lead_advance(state.game_id, True) + + @pytest.mark.asyncio + async def test_safe_out_wrong_phase(self, engine): + """Submitting safe/out when not in awaiting_uncapped_safe_out raises ValueError.""" + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + state.decision_phase = "awaiting_uncapped_trail_advance" # Wrong + state.pending_uncapped_hit = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id="test", + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=3, batter_base=1, batter_lineup_id=BATTER_LID, + ) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm): + with pytest.raises(ValueError, match="Wrong phase"): + await engine.submit_uncapped_safe_out(state.game_id, "safe") + + @pytest.mark.asyncio + async def test_safe_out_invalid_value(self, engine): + """Submitting invalid result (not 'safe' or 'out') raises ValueError.""" + state = make_state(on_first=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + state.decision_phase = "awaiting_uncapped_safe_out" + state.pending_uncapped_hit = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id="test", + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=3, batter_base=1, batter_lineup_id=BATTER_LID, + ) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm): + with pytest.raises(ValueError, match="result must be 'safe' or 'out'"): + await engine.submit_uncapped_safe_out(state.game_id, "maybe") + + @pytest.mark.asyncio + async def test_throw_target_invalid_value(self, engine): + """Submitting invalid throw target raises ValueError.""" + state = make_state(on_first=True, on_second=True) + state.pending_manual_roll = make_ab_roll(state.game_id) + state.decision_phase = "awaiting_uncapped_throw_target" + state.pending_uncapped_hit = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id="test", + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=1, + trail_runner_lineup_id=R1_LID, trail_target_base=3, + batter_base=1, batter_lineup_id=BATTER_LID, + ) + + mock_sm = MockStateManager(state) + with patch("app.core.game_engine.state_manager", mock_sm): + with pytest.raises(ValueError, match="throw_target must be"): + await engine.submit_uncapped_throw_target(state.game_id, "neither") + + +# ============================================================================= +# Tests: Build result methods directly +# ============================================================================= + + +class TestBuildResults: + """Test the _build_uncapped_* result builder methods directly.""" + + def test_fallback_result_single(self, engine): + """_build_uncapped_fallback_result for single produces SINGLE_1 equivalent.""" + state = make_state(on_first=True, on_third=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="LF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=3, batter_base=1, batter_lineup_id=BATTER_LID, + ) + + result = engine._build_uncapped_fallback_result(state, pending, ab_roll) + assert result.outcome == PlayOutcome.SINGLE_UNCAPPED + assert result.batter_result == 1 + assert result.is_hit is True + # SINGLE_1 with R1+R3: R3 scores, R1→2nd + assert result.runs_scored == 1 # R3 scores + + def test_fallback_result_double(self, engine): + """_build_uncapped_fallback_result for double produces DOUBLE_2 equivalent.""" + state = make_state(on_first=True, on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="double", hit_location="CF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=4, batter_base=2, batter_lineup_id=BATTER_LID, + ) + + result = engine._build_uncapped_fallback_result(state, pending, ab_roll) + assert result.outcome == PlayOutcome.DOUBLE_UNCAPPED + assert result.batter_result == 2 + # DOUBLE_2 with R1+R2: R2 scores, R1→3rd + assert result.runs_scored == 1 # R2 scores + + def test_no_throw_result_single_r2(self, engine): + """ + _build_uncapped_no_throw_result for single with R2: + R2 scores (lead advances safely), batter to 1st. + No trail runner on base (trail is batter, base=0). + """ + state = make_state(on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=0, + trail_runner_lineup_id=BATTER_LID, trail_target_base=2, + batter_base=1, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=False, + ) + + result = engine._build_uncapped_no_throw_result(state, pending, ab_roll) + assert result.runs_scored == 1 # R2 scores + assert result.outs_recorded == 0 + assert result.batter_result == 1 + + def test_no_throw_result_single_r1_r2(self, engine): + """ + _build_uncapped_no_throw_result for single with R1+R2: + R2 scores (lead), R1→2nd (trail advances one base), batter to 1st. + """ + state = make_state(on_first=True, on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="RF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=1, + trail_runner_lineup_id=R1_LID, trail_target_base=3, + batter_base=1, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=False, + ) + + result = engine._build_uncapped_no_throw_result(state, pending, ab_roll) + assert result.runs_scored == 1 # R2 scores + assert result.outs_recorded == 0 + + # Check runner movements + movements = {(a.from_base, a.to_base) for a in result.runners_advanced} + assert (2, 4) in movements # R2 scores + assert (1, 2) in movements # R1→2nd (trail gets standard +1) + + def test_play_result_lead_safe_no_trail(self, engine): + """ + _build_uncapped_play_result: lead thrown at and safe, no throw_target + (no trail runner advanced, just lead). + """ + state = make_state(on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=0, + trail_runner_lineup_id=BATTER_LID, trail_target_base=2, + batter_base=1, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=True, + trail_advance=False, + speed_check_d20=12, speed_check_runner="lead", + speed_check_result="safe", + ) + + result = engine._build_uncapped_play_result(state, pending, ab_roll) + assert result.runs_scored == 1 # R2 scores + assert result.outs_recorded == 0 + assert result.is_hit is True + + def test_play_result_lead_out(self, engine): + """ + _build_uncapped_play_result: lead thrown at and OUT. + """ + state = make_state(on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="CF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=0, + trail_runner_lineup_id=BATTER_LID, trail_target_base=2, + batter_base=1, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=True, + trail_advance=False, + speed_check_d20=3, speed_check_runner="lead", + speed_check_result="out", + ) + + result = engine._build_uncapped_play_result(state, pending, ab_roll) + assert result.runs_scored == 0 + assert result.outs_recorded == 1 + assert result.is_out is True + + def test_play_result_throw_at_lead_trail_auto_advances(self, engine): + """ + _build_uncapped_play_result: throw at lead, trail auto-advances. + Lead safe → both advance. + """ + state = make_state(on_first=True, on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="LF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=1, + trail_runner_lineup_id=R1_LID, trail_target_base=3, + batter_base=1, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=True, + trail_advance=True, throw_target="lead", + speed_check_d20=15, speed_check_runner="lead", + speed_check_result="safe", + ) + + result = engine._build_uncapped_play_result(state, pending, ab_roll) + assert result.runs_scored == 1 # R2 scores + assert result.outs_recorded == 0 + + movements = {(a.from_base, a.to_base) for a in result.runners_advanced} + assert (2, 4) in movements # R2 scores (lead, safe) + assert (1, 3) in movements # R1 auto-advances (trail, not thrown at) + + def test_play_result_throw_at_trail_lead_auto_advances(self, engine): + """ + _build_uncapped_play_result: throw at trail, lead auto-advances. + Trail out → lead scores, trail out. + """ + state = make_state(on_first=True, on_second=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="single", hit_location="RF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=2, lead_runner_lineup_id=R2_LID, + lead_target_base=4, trail_runner_base=1, + trail_runner_lineup_id=R1_LID, trail_target_base=3, + batter_base=1, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=True, + trail_advance=True, throw_target="trail", + speed_check_d20=2, speed_check_runner="trail", + speed_check_result="out", + ) + + result = engine._build_uncapped_play_result(state, pending, ab_roll) + assert result.runs_scored == 1 # R2 auto-advances and scores + assert result.outs_recorded == 1 # R1 is out + + movements = {(a.from_base, a.to_base) for a in result.runners_advanced} + assert (2, 4) in movements # R2 scores (lead, auto-advance) + assert (1, 0) in movements # R1 out (trail, thrown out) + + def test_play_result_with_auto_runners(self, engine): + """ + _build_uncapped_play_result with auto-scoring runners (loaded bases double). + R3+R2 auto-score, lead (R1) safe → 3 runs total. + """ + state = make_state(on_first=True, on_second=True, on_third=True) + ab_roll = make_ab_roll(state.game_id) + pending = PendingUncappedHit( + hit_type="double", hit_location="CF", ab_roll_id=ab_roll.roll_id, + lead_runner_base=1, lead_runner_lineup_id=R1_LID, + lead_target_base=4, trail_runner_base=0, + trail_runner_lineup_id=BATTER_LID, trail_target_base=3, + auto_runners=[(3, 4, R3_LID), (2, 4, R2_LID)], + batter_base=2, batter_lineup_id=BATTER_LID, + lead_advance=True, defensive_throw=True, + trail_advance=False, + speed_check_d20=18, speed_check_runner="lead", + speed_check_result="safe", + ) + + result = engine._build_uncapped_play_result(state, pending, ab_roll) + assert result.runs_scored == 3 # R3 + R2 auto + R1 safe + assert result.outs_recorded == 0 diff --git a/backend/tests/unit/core/truth_tables/test_tt_uncapped_hits.py b/backend/tests/unit/core/truth_tables/test_tt_uncapped_hits.py new file mode 100644 index 0000000..1668b09 --- /dev/null +++ b/backend/tests/unit/core/truth_tables/test_tt_uncapped_hits.py @@ -0,0 +1,124 @@ +""" +Truth Table Tests: Uncapped Hit Fallback Outcomes + +Verifies that SINGLE_UNCAPPED and DOUBLE_UNCAPPED produce the correct fallback +advancement when no eligible runners exist for the interactive decision tree. + +When GameEngine determines no decision is needed (no eligible lead runner), +the PlayResolver handles the outcome directly: + - SINGLE_UNCAPPED → SINGLE_1 equivalent (R3 scores, R2→3rd, R1→2nd) + - DOUBLE_UNCAPPED → DOUBLE_2 equivalent (R1→3rd, R2/R3 score) + +The interactive decision tree (handled by GameEngine) is tested separately +in test_uncapped_hit_workflow.py. + +Fallback conditions: + SINGLE_UNCAPPED: No R1 AND no R2 → on_base_codes 0 (empty), 3 (R3 only) + DOUBLE_UNCAPPED: No R1 → on_base_codes 0 (empty), 2 (R2), 3 (R3), 6 (R2+R3) + +On-base codes (sequential chart encoding): + 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded + +Author: Claude +Date: 2025-02-11 +""" + +import pytest + +from app.config import PlayOutcome + +from .conftest import OBC_LABELS, assert_play_result, resolve_simple + + +# ============================================================================= +# Truth Table +# ============================================================================= +# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs) + +UNCAPPED_FALLBACK_TRUTH_TABLE = [ + # ========================================================================= + # SINGLE_UNCAPPED fallback: Same as SINGLE_1 (R3 scores, R2→3rd, R1→2nd) + # Only these on_base_codes reach PlayResolver (no R1 AND no R2): + # 0 = empty, 3 = R3 only + # ========================================================================= + (PlayOutcome.SINGLE_UNCAPPED, 0, 1, [], 0, 0), # Empty - just batter to 1st + (PlayOutcome.SINGLE_UNCAPPED, 3, 1, [(3, 4)], 1, 0), # R3 scores + + # ========================================================================= + # DOUBLE_UNCAPPED fallback: Same as DOUBLE_2 (all runners +2 bases) + # Only these on_base_codes reach PlayResolver (no R1): + # 0 = empty, 2 = R2, 3 = R3, 6 = R2+R3 + # ========================================================================= + (PlayOutcome.DOUBLE_UNCAPPED, 0, 2, [], 0, 0), # Empty - batter to 2nd + (PlayOutcome.DOUBLE_UNCAPPED, 2, 2, [(2, 4)], 1, 0), # R2 scores (+2 = home) + (PlayOutcome.DOUBLE_UNCAPPED, 3, 2, [(3, 4)], 1, 0), # R3 scores (+2 = home) + (PlayOutcome.DOUBLE_UNCAPPED, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 both score +] + +# Generate human-readable test IDs +UNCAPPED_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}" + for outcome, obc, *_ in UNCAPPED_FALLBACK_TRUTH_TABLE +] + + +# ============================================================================= +# Tests +# ============================================================================= + +class TestUncappedFallbackTruthTable: + """ + Verify that uncapped hit outcomes without eligible runners produce + the correct standard advancement (SINGLE_1 / DOUBLE_2 equivalent). + """ + + @pytest.mark.parametrize( + "outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs", + UNCAPPED_FALLBACK_TRUTH_TABLE, + ids=UNCAPPED_IDS, + ) + def test_uncapped_fallback_advancement( + self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs + ): + """ + Verify that an uncapped hit with no eligible runners for the decision + tree produces the exact expected batter result, runner movements, + runs, and outs — equivalent to the standard SINGLE_1 / DOUBLE_2 rules. + """ + result = resolve_simple(outcome, obc) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})", + ) + + +class TestUncappedFallbackCompleteness: + """Verify the truth table covers all fallback on_base_codes.""" + + def test_single_uncapped_fallback_codes(self): + """ + SINGLE_UNCAPPED should only reach PlayResolver for obc 0 and 3 + (empty and R3 only — no R1 or R2 to trigger decision tree). + """ + entries = [ + row for row in UNCAPPED_FALLBACK_TRUTH_TABLE + if row[0] == PlayOutcome.SINGLE_UNCAPPED + ] + obcs = {row[1] for row in entries} + assert obcs == {0, 3}, f"Expected {{0, 3}}, got {obcs}" + + def test_double_uncapped_fallback_codes(self): + """ + DOUBLE_UNCAPPED should only reach PlayResolver for obc 0, 2, 3, 6 + (no R1 to trigger decision tree). + """ + entries = [ + row for row in UNCAPPED_FALLBACK_TRUTH_TABLE + if row[0] == PlayOutcome.DOUBLE_UNCAPPED + ] + obcs = {row[1] for row in entries} + assert obcs == {0, 2, 3, 6}, f"Expected {{0, 2, 3, 6}}, got {obcs}" diff --git a/backend/tests/unit/websocket/test_uncapped_hit_handlers.py b/backend/tests/unit/websocket/test_uncapped_hit_handlers.py new file mode 100644 index 0000000..2679563 --- /dev/null +++ b/backend/tests/unit/websocket/test_uncapped_hit_handlers.py @@ -0,0 +1,385 @@ +""" +Tests: Uncapped Hit WebSocket Handlers + +Verifies the 5 new WebSocket event handlers for uncapped hit decisions: + - submit_uncapped_lead_advance + - submit_uncapped_defensive_throw + - submit_uncapped_trail_advance + - submit_uncapped_throw_target + - submit_uncapped_safe_out + +Tests cover: + - Missing/invalid game_id handling + - Missing/invalid field-specific input validation + - Successful submission forwarding to game engine + - State broadcast after successful submission + - ValueError propagation from game engine + +Author: Claude +Date: 2025-02-11 +""" + +import pytest +from uuid import uuid4 +from unittest.mock import AsyncMock, MagicMock, patch + +from app.models.game_models import GameState, LineupPlayerState + +from .conftest import get_handler, sio_with_mocks + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_game_state(): + """Create a mock active game state for handler tests.""" + return GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=LineupPlayerState( + lineup_id=1, card_id=100, position="CF", batting_order=1 + ), + status="active", + inning=1, + half="top", + outs=0, + ) + + +# ============================================================================= +# Tests: submit_uncapped_lead_advance +# ============================================================================= + + +class TestSubmitUncappedLeadAdvance: + """Tests for the submit_uncapped_lead_advance WebSocket handler.""" + + @pytest.mark.asyncio + async def test_missing_game_id(self, sio_with_mocks): + """Handler emits error when game_id is not provided.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_lead_advance") + await handler("test_sid", {"advance": True}) + mocks["manager"].emit_to_user.assert_called_once() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + assert "game_id" in call_args[2]["message"].lower() + + @pytest.mark.asyncio + async def test_invalid_game_id(self, sio_with_mocks): + """Handler emits error when game_id is not a valid UUID.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_lead_advance") + await handler("test_sid", {"game_id": "not-a-uuid", "advance": True}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + + @pytest.mark.asyncio + async def test_missing_advance_field(self, sio_with_mocks): + """Handler emits error when advance field is missing.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_lead_advance") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + assert "advance" in call_args[2]["message"].lower() + + @pytest.mark.asyncio + async def test_invalid_advance_type(self, sio_with_mocks): + """Handler emits error when advance is not a bool.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_lead_advance") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id, "advance": "yes"}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + + @pytest.mark.asyncio + async def test_successful_submission(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine and broadcasts state on success.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_lead_advance") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_lead_advance = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "advance": True}) + + mocks["game_engine"].submit_uncapped_lead_advance.assert_called_once_with( + mock_game_state.game_id, True + ) + mocks["manager"].broadcast_to_game.assert_called_once() + + @pytest.mark.asyncio + async def test_value_error_from_engine(self, sio_with_mocks): + """Handler emits error when game engine raises ValueError.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_lead_advance") + game_id = str(uuid4()) + + mocks["game_engine"].submit_uncapped_lead_advance = AsyncMock( + side_effect=ValueError("Wrong phase") + ) + + await handler("test_sid", {"game_id": game_id, "advance": True}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + assert "Wrong phase" in call_args[2]["message"] + + +# ============================================================================= +# Tests: submit_uncapped_defensive_throw +# ============================================================================= + + +class TestSubmitUncappedDefensiveThrow: + """Tests for the submit_uncapped_defensive_throw WebSocket handler.""" + + @pytest.mark.asyncio + async def test_missing_game_id(self, sio_with_mocks): + """Handler emits error when game_id is not provided.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_defensive_throw") + await handler("test_sid", {"will_throw": True}) + mocks["manager"].emit_to_user.assert_called_once() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + + @pytest.mark.asyncio + async def test_missing_will_throw_field(self, sio_with_mocks): + """Handler emits error when will_throw field is missing.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_defensive_throw") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert "will_throw" in call_args[2]["message"].lower() + + @pytest.mark.asyncio + async def test_successful_submission(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine and broadcasts state on success.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_defensive_throw") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_defensive_throw = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "will_throw": False}) + + mocks["game_engine"].submit_uncapped_defensive_throw.assert_called_once_with( + mock_game_state.game_id, False + ) + mocks["manager"].broadcast_to_game.assert_called_once() + + +# ============================================================================= +# Tests: submit_uncapped_trail_advance +# ============================================================================= + + +class TestSubmitUncappedTrailAdvance: + """Tests for the submit_uncapped_trail_advance WebSocket handler.""" + + @pytest.mark.asyncio + async def test_missing_game_id(self, sio_with_mocks): + """Handler emits error when game_id is not provided.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_trail_advance") + await handler("test_sid", {"advance": True}) + mocks["manager"].emit_to_user.assert_called_once() + + @pytest.mark.asyncio + async def test_missing_advance_field(self, sio_with_mocks): + """Handler emits error when advance field is missing.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_trail_advance") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert "advance" in call_args[2]["message"].lower() + + @pytest.mark.asyncio + async def test_successful_submission(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine and broadcasts state on success.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_trail_advance") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_trail_advance = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "advance": True}) + + mocks["game_engine"].submit_uncapped_trail_advance.assert_called_once_with( + mock_game_state.game_id, True + ) + + +# ============================================================================= +# Tests: submit_uncapped_throw_target +# ============================================================================= + + +class TestSubmitUncappedThrowTarget: + """Tests for the submit_uncapped_throw_target WebSocket handler.""" + + @pytest.mark.asyncio + async def test_missing_game_id(self, sio_with_mocks): + """Handler emits error when game_id is not provided.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_throw_target") + await handler("test_sid", {"target": "lead"}) + mocks["manager"].emit_to_user.assert_called_once() + + @pytest.mark.asyncio + async def test_invalid_target(self, sio_with_mocks): + """Handler emits error when target is not 'lead' or 'trail'.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_throw_target") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id, "target": "middle"}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + assert "lead" in call_args[2]["message"] or "trail" in call_args[2]["message"] + + @pytest.mark.asyncio + async def test_missing_target(self, sio_with_mocks): + """Handler emits error when target field is missing.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_throw_target") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id}) + mocks["manager"].emit_to_user.assert_called() + + @pytest.mark.asyncio + async def test_successful_submission_lead(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine with target='lead' and broadcasts state.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_throw_target") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_throw_target = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "target": "lead"}) + + mocks["game_engine"].submit_uncapped_throw_target.assert_called_once_with( + mock_game_state.game_id, "lead" + ) + + @pytest.mark.asyncio + async def test_successful_submission_trail(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine with target='trail' and broadcasts state.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_throw_target") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_throw_target = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "target": "trail"}) + + mocks["game_engine"].submit_uncapped_throw_target.assert_called_once_with( + mock_game_state.game_id, "trail" + ) + + +# ============================================================================= +# Tests: submit_uncapped_safe_out +# ============================================================================= + + +class TestSubmitUncappedSafeOut: + """Tests for the submit_uncapped_safe_out WebSocket handler.""" + + @pytest.mark.asyncio + async def test_missing_game_id(self, sio_with_mocks): + """Handler emits error when game_id is not provided.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_safe_out") + await handler("test_sid", {"result": "safe"}) + mocks["manager"].emit_to_user.assert_called_once() + + @pytest.mark.asyncio + async def test_invalid_result(self, sio_with_mocks): + """Handler emits error when result is not 'safe' or 'out'.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_safe_out") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id, "result": "maybe"}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert call_args[1] == "error" + + @pytest.mark.asyncio + async def test_missing_result(self, sio_with_mocks): + """Handler emits error when result field is missing.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_safe_out") + game_id = str(uuid4()) + await handler("test_sid", {"game_id": game_id}) + mocks["manager"].emit_to_user.assert_called() + + @pytest.mark.asyncio + async def test_successful_safe(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine with result='safe' and broadcasts state.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_safe_out") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_safe_out = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "result": "safe"}) + + mocks["game_engine"].submit_uncapped_safe_out.assert_called_once_with( + mock_game_state.game_id, "safe" + ) + mocks["manager"].broadcast_to_game.assert_called_once() + + @pytest.mark.asyncio + async def test_successful_out(self, sio_with_mocks, mock_game_state): + """Handler calls game_engine with result='out' and broadcasts state.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_safe_out") + game_id = str(mock_game_state.game_id) + + mocks["game_engine"].submit_uncapped_safe_out = AsyncMock() + mocks["state_manager"].get_state = MagicMock(return_value=mock_game_state) + + await handler("test_sid", {"game_id": game_id, "result": "out"}) + + mocks["game_engine"].submit_uncapped_safe_out.assert_called_once_with( + mock_game_state.game_id, "out" + ) + + @pytest.mark.asyncio + async def test_value_error_propagation(self, sio_with_mocks): + """Handler emits error when game engine raises ValueError.""" + sio, mocks = sio_with_mocks + handler = get_handler(sio, "submit_uncapped_safe_out") + game_id = str(uuid4()) + + mocks["game_engine"].submit_uncapped_safe_out = AsyncMock( + side_effect=ValueError("No pending uncapped hit") + ) + + await handler("test_sid", {"game_id": game_id, "result": "safe"}) + mocks["manager"].emit_to_user.assert_called() + call_args = mocks["manager"].emit_to_user.call_args[0] + assert "No pending uncapped hit" in call_args[2]["message"] diff --git a/frontend-sba/tests/unit/components/Decisions/DefensiveSetup.spec.ts b/frontend-sba/tests/unit/components/Decisions/DefensiveSetup.spec.ts index e689ed5..b1a2a52 100644 --- a/frontend-sba/tests/unit/components/Decisions/DefensiveSetup.spec.ts +++ b/frontend-sba/tests/unit/components/Decisions/DefensiveSetup.spec.ts @@ -43,17 +43,7 @@ describe("DefensiveSetup", () => { expect(wrapper.text()).toContain("Infield Depth"); expect(wrapper.text()).toContain("Outfield Depth"); - expect(wrapper.text()).toContain("Hold Runners"); - }); - - it("shows hint text directing users to runner pills", () => { - const wrapper = mount(DefensiveSetup, { - props: defaultProps, - }); - - expect(wrapper.text()).toContain( - "Tap the H icons on the runner pills above", - ); + expect(wrapper.text()).toContain("Current Setup"); }); }); @@ -95,18 +85,19 @@ describe("DefensiveSetup", () => { }); describe("Hold Runners Display", () => { - it('shows "None" when no runners held', () => { + it('shows "None" when no runners held in preview', () => { const wrapper = mount(DefensiveSetup, { props: defaultProps, }); - expect(wrapper.text()).toContain("None"); + // Check preview section shows "None" for holding + expect(wrapper.text()).toContain("Holding:None"); }); - it("shows held bases as amber badges when runners are held", () => { + it("displays holding status in preview for held runners", () => { /** - * When the composable has held runners, the DefensiveSetup should - * display them as read-only amber pill badges. + * The preview section should show a comma-separated list of held bases. + * Hold runner UI has moved to the runner pills themselves. */ const { syncFromDecision } = useDefensiveSetup(); syncFromDecision({ @@ -119,11 +110,8 @@ describe("DefensiveSetup", () => { props: defaultProps, }); - expect(wrapper.text()).toContain("1st"); - expect(wrapper.text()).toContain("3rd"); - // Verify amber badges exist - const badges = wrapper.findAll(".bg-amber-100"); - expect(badges.length).toBe(2); + // Preview should show the held bases + expect(wrapper.text()).toContain("Holding:1st, 3rd"); }); it("displays holding status in preview for multiple runners", () => { @@ -141,9 +129,7 @@ describe("DefensiveSetup", () => { props: defaultProps, }); - expect(wrapper.text()).toContain("1st"); - expect(wrapper.text()).toContain("2nd"); - expect(wrapper.text()).toContain("3rd"); + expect(wrapper.text()).toContain("Holding:1st, 2nd, 3rd"); }); }); diff --git a/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts b/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts index 555e52b..17606c4 100644 --- a/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts +++ b/frontend-sba/tests/unit/components/Gameplay/GameplayPanel.spec.ts @@ -1,12 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue' import type { RollData, PlayResult } from '~/types' import DiceRoller from '~/components/Gameplay/DiceRoller.vue' import OutcomeWizard from '~/components/Gameplay/OutcomeWizard.vue' -import PlayResultComponent from '~/components/Gameplay/PlayResult.vue' +import PlayResultDisplay from '~/components/Gameplay/PlayResult.vue' describe('GameplayPanel', () => { + let pinia: ReturnType + const createRollData = (): RollData => ({ roll_id: 'test-roll-123', d6_one: 3, @@ -44,7 +47,16 @@ describe('GameplayPanel', () => { canSubmitOutcome: false, } + const mountPanel = (propsOverride = {}) => { + return mount(GameplayPanel, { + global: { plugins: [pinia] }, + props: { ...defaultProps, ...propsOverride }, + }) + } + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) vi.clearAllTimers() }) @@ -54,18 +66,14 @@ describe('GameplayPanel', () => { describe('Rendering', () => { it('renders gameplay panel container', () => { - const wrapper = mount(GameplayPanel, { - props: defaultProps, - }) + const wrapper = mountPanel() expect(wrapper.find('.gameplay-panel').exists()).toBe(true) expect(wrapper.text()).toContain('Gameplay') }) it('renders panel header with status', () => { - const wrapper = mount(GameplayPanel, { - props: defaultProps, - }) + const wrapper = mountPanel() expect(wrapper.find('.panel-header').exists()).toBe(true) expect(wrapper.find('.status-indicator').exists()).toBe(true) @@ -79,21 +87,14 @@ describe('GameplayPanel', () => { describe('Workflow State: Idle', () => { it('shows idle state when canRollDice is false', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: false, - }, - }) + const wrapper = mountPanel({ canRollDice: false }) expect(wrapper.find('.state-idle').exists()).toBe(true) expect(wrapper.text()).toContain('Waiting for strategic decisions') }) it('displays idle status indicator', () => { - const wrapper = mount(GameplayPanel, { - props: defaultProps, - }) + const wrapper = mountPanel() expect(wrapper.find('.status-idle').exists()).toBe(true) expect(wrapper.find('.status-text').text()).toBe('Waiting') @@ -106,63 +107,33 @@ describe('GameplayPanel', () => { describe('Workflow State: Ready to Roll', () => { it('shows ready state when canRollDice is true and my turn', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: true, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: true }) expect(wrapper.find('.state-ready').exists()).toBe(true) expect(wrapper.text()).toContain('Your turn! Roll the dice') }) it('shows waiting message when not my turn', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: false, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: false }) expect(wrapper.text()).toContain('Waiting for opponent to roll dice') }) it('renders DiceRoller component when my turn', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: true, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: true }) expect(wrapper.findComponent(DiceRoller).exists()).toBe(true) }) it('displays active status when ready and my turn', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: true, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: true }) expect(wrapper.find('.status-active').exists()).toBe(true) expect(wrapper.find('.status-text').text()).toBe('Your Turn') }) it('displays opponent turn status when ready but not my turn', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: false, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: false }) expect(wrapper.find('.status-text').text()).toBe('Opponent Turn') }) @@ -174,12 +145,9 @@ describe('GameplayPanel', () => { describe('Workflow State: Rolled', () => { it('shows rolled state when pendingRoll exists', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - pendingRoll: createRollData(), - canSubmitOutcome: true, - }, + const wrapper = mountPanel({ + pendingRoll: createRollData(), + canSubmitOutcome: true, }) expect(wrapper.find('.state-rolled').exists()).toBe(true) @@ -187,12 +155,9 @@ describe('GameplayPanel', () => { it('renders DiceRoller with roll results', () => { const rollData = createRollData() - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - pendingRoll: rollData, - canSubmitOutcome: true, - }, + const wrapper = mountPanel({ + pendingRoll: rollData, + canSubmitOutcome: true, }) const diceRoller = wrapper.findComponent(DiceRoller) @@ -202,24 +167,18 @@ describe('GameplayPanel', () => { }) it('renders OutcomeWizard component', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - pendingRoll: createRollData(), - canSubmitOutcome: true, - }, + const wrapper = mountPanel({ + pendingRoll: createRollData(), + canSubmitOutcome: true, }) expect(wrapper.findComponent(OutcomeWizard).exists()).toBe(true) }) it('displays active status when outcome entry active', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - pendingRoll: createRollData(), - canSubmitOutcome: true, - }, + const wrapper = mountPanel({ + pendingRoll: createRollData(), + canSubmitOutcome: true, }) expect(wrapper.find('.status-active').exists()).toBe(true) @@ -233,50 +192,32 @@ describe('GameplayPanel', () => { describe('Workflow State: Result', () => { it('shows result state when lastPlayResult exists', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - lastPlayResult: createPlayResult(), - }, - }) + const wrapper = mountPanel({ lastPlayResult: createPlayResult() }) expect(wrapper.find('.state-result').exists()).toBe(true) }) - it('renders PlayResult component', () => { + it('renders PlayResultDisplay component', () => { const playResult = createPlayResult() - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - lastPlayResult: playResult, - }, - }) + const wrapper = mountPanel({ lastPlayResult: playResult }) - const playResultComponent = wrapper.findComponent(PlayResultComponent) - expect(playResultComponent.exists()).toBe(true) - expect(playResultComponent.props('result')).toEqual(playResult) + const resultComponent = wrapper.findComponent(PlayResultDisplay) + expect(resultComponent.exists()).toBe(true) + expect(resultComponent.props('result')).toEqual(playResult) }) it('displays success status when result shown', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - lastPlayResult: createPlayResult(), - }, - }) + const wrapper = mountPanel({ lastPlayResult: createPlayResult() }) expect(wrapper.find('.status-success').exists()).toBe(true) expect(wrapper.find('.status-text').text()).toBe('Play Complete') }) it('prioritizes result state over other states', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - pendingRoll: createRollData(), - lastPlayResult: createPlayResult(), - }, + const wrapper = mountPanel({ + canRollDice: true, + pendingRoll: createRollData(), + lastPlayResult: createPlayResult(), }) expect(wrapper.find('.state-result').exists()).toBe(true) @@ -291,13 +232,7 @@ describe('GameplayPanel', () => { describe('Event Emission', () => { it('emits rollDice when DiceRoller emits roll', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: true, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: true }) const diceRoller = wrapper.findComponent(DiceRoller) await diceRoller.vm.$emit('roll') @@ -306,13 +241,10 @@ describe('GameplayPanel', () => { expect(wrapper.emitted('rollDice')).toHaveLength(1) }) - it('emits submitOutcome when ManualOutcomeEntry submits', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - pendingRoll: createRollData(), - canSubmitOutcome: true, - }, + it('emits submitOutcome when OutcomeWizard submits', async () => { + const wrapper = mountPanel({ + pendingRoll: createRollData(), + canSubmitOutcome: true, }) const outcomeWizard = wrapper.findComponent(OutcomeWizard) @@ -323,16 +255,11 @@ describe('GameplayPanel', () => { expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload]) }) - it('emits dismissResult when PlayResult emits dismiss', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - lastPlayResult: createPlayResult(), - }, - }) + it('emits dismissResult when PlayResultDisplay emits dismiss', async () => { + const wrapper = mountPanel({ lastPlayResult: createPlayResult() }) - const playResult = wrapper.findComponent(PlayResultComponent) - await playResult.vm.$emit('dismiss') + const resultComponent = wrapper.findComponent(PlayResultDisplay) + await resultComponent.vm.$emit('dismiss') expect(wrapper.emitted('dismissResult')).toBeTruthy() expect(wrapper.emitted('dismissResult')).toHaveLength(1) @@ -345,9 +272,7 @@ describe('GameplayPanel', () => { describe('Workflow State Transitions', () => { it('transitions from idle to ready when canRollDice becomes true', async () => { - const wrapper = mount(GameplayPanel, { - props: defaultProps, - }) + const wrapper = mountPanel() expect(wrapper.find('.state-idle').exists()).toBe(true) @@ -358,13 +283,7 @@ describe('GameplayPanel', () => { }) it('transitions from ready to rolled when pendingRoll set', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: true, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: true }) expect(wrapper.find('.state-ready').exists()).toBe(true) @@ -379,12 +298,9 @@ describe('GameplayPanel', () => { }) it('transitions from rolled to result when lastPlayResult set', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - pendingRoll: createRollData(), - canSubmitOutcome: true, - }, + const wrapper = mountPanel({ + pendingRoll: createRollData(), + canSubmitOutcome: true, }) expect(wrapper.find('.state-rolled').exists()).toBe(true) @@ -399,12 +315,7 @@ describe('GameplayPanel', () => { }) it('transitions from result to idle when result dismissed', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - lastPlayResult: createPlayResult(), - }, - }) + const wrapper = mountPanel({ lastPlayResult: createPlayResult() }) expect(wrapper.find('.state-result').exists()).toBe(true) @@ -421,9 +332,7 @@ describe('GameplayPanel', () => { describe('Edge Cases', () => { it('handles multiple rapid state changes', async () => { - const wrapper = mount(GameplayPanel, { - props: defaultProps, - }) + const wrapper = mountPanel() await wrapper.setProps({ canRollDice: true, isMyTurn: true }) await wrapper.setProps({ pendingRoll: createRollData(), canSubmitOutcome: true }) @@ -433,39 +342,26 @@ describe('GameplayPanel', () => { }) it('handles missing gameId gracefully', () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - gameId: '', - }, - }) + const wrapper = mountPanel({ gameId: '' }) expect(wrapper.find('.gameplay-panel').exists()).toBe(true) }) it('handles all props being null/false', () => { - const wrapper = mount(GameplayPanel, { - props: { - gameId: 'test', - isMyTurn: false, - canRollDice: false, - pendingRoll: null, - lastPlayResult: null, - canSubmitOutcome: false, - }, + const wrapper = mountPanel({ + gameId: 'test', + isMyTurn: false, + canRollDice: false, + pendingRoll: null, + lastPlayResult: null, + canSubmitOutcome: false, }) expect(wrapper.find('.state-idle').exists()).toBe(true) }) it('clears error when rolling dice', async () => { - const wrapper = mount(GameplayPanel, { - props: { - ...defaultProps, - canRollDice: true, - isMyTurn: true, - }, - }) + const wrapper = mountPanel({ canRollDice: true, isMyTurn: true }) // Manually set error (would normally come from failed operation) wrapper.vm.error = 'Test error'