From fa3fadd14c34394aa2865bf7732d8e05492798bd Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Feb 2026 13:54:57 -0600 Subject: [PATCH] CLAUDE: Implement uncapped hit decision UI + backend bugfixes (Issue #7) Frontend: Full 5-phase interactive wizard for uncapped hit decisions (lead advance, defensive throw, trail advance, throw target, safe/out) with mobile-first design, offense/defense role switching, and auto- clearing on workflow completion. Backend fixes: - Remove nested asyncio.Lock acquisition causing deadlocks in all submit_uncapped_* methods and initiate_uncapped_hit (non-re-entrant) - Preserve pending_manual_roll during interactive workflows - Add league_id to all dice_system.roll_d20() calls - Extract D20Roll.roll int for state serialization - Fix batter-runner not advancing when non-targeted in throw - Fix rollback_plays not recalculating scores from remaining plays Files: 10 modified, 1 new (UncappedHitWizard.vue) Tests: 2481/2481 passing Co-Authored-By: Claude Opus 4.6 --- backend/app/core/game_engine.py | 832 +++++++++--------- backend/app/websocket/handlers.py | 123 ++- frontend-sba/components/Game/GamePlay.vue | 56 +- .../components/Gameplay/GameplayPanel.vue | 94 +- .../components/Gameplay/UncappedHitWizard.vue | 536 +++++++++++ frontend-sba/composables/useGameActions.ts | 91 ++ frontend-sba/composables/useWebSocket.ts | 56 +- frontend-sba/store/game.ts | 61 ++ frontend-sba/types/game.ts | 105 +++ frontend-sba/types/index.ts | 14 + frontend-sba/types/websocket.ts | 32 + 11 files changed, 1555 insertions(+), 445 deletions(-) create mode 100644 frontend-sba/components/Gameplay/UncappedHitWizard.vue diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index cd4cee7..3b45b97 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -932,11 +932,13 @@ class GameEngine: # Check if SPD is in any column - if so, pre-roll d20 spd_d20 = None if "SPD" in chart_row: - spd_d20 = dice_system.roll_d20( + spd_d20_result = dice_system.roll_d20( + league_id=state.league_id, game_id=game_id, team_id=state.get_fielding_team_id(), player_id=None, ) + spd_d20 = spd_d20_result.roll if hasattr(spd_d20_result, 'roll') else spd_d20_result # Get defender at this position defender = state.get_defender_for_position(position, state_manager) @@ -1157,128 +1159,130 @@ class GameEngine: outcome: SINGLE_UNCAPPED or DOUBLE_UNCAPPED hit_location: Outfield position (LF, CF, RF) ab_roll: The at-bat roll for audit trail + + Note: Caller must hold the game lock (called from resolve_manual_play + which runs inside the handler's game_lock context). """ - 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") + state = state_manager.get_state(game_id) + if not state: + raise ValueError(f"Game {game_id} not found") - game_validator.validate_game_active(state) + 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 + 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" + # Default hit_location to CF if not provided + location = hit_location or "CF" - auto_runners: list[tuple[int, int, int]] = [] + 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)) + 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)) + # 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: - # 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 + trail_base = 1 + trail_lid = state.on_first.lineup_id + trail_target = 3 # R1 attempting 3rd else: - # Should not reach here - raise ValueError("DOUBLE_UNCAPPED with no R1 should use fallback") + 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 - # 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, - ) + # 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)) - # Store in state - state.pending_uncapped_hit = pending - state.decision_phase = "awaiting_uncapped_lead_advance" - state.pending_decision = "uncapped_lead_advance" + if state.on_first: + # Lead = R1 attempting HOME + lead_base = 1 + lead_lid = state.on_first.lineup_id + lead_target = 4 # HOME - state_manager.update_state(game_id, state) + # 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") - logger.info( - f"Uncapped {hit_type} initiated for game {game_id}: " - f"lead=base{lead_base}→{lead_target}, trail=base{trail_base}→{trail_target}" - ) + # 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, + ) - # 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 + # Store in state + state.pending_uncapped_hit = pending + state.decision_phase = "awaiting_uncapped_lead_advance" + state.pending_decision = "uncapped_lead_advance" - # 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, - }, - ) + 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 @@ -1288,60 +1292,61 @@ class GameEngine: If NO: fallback to standard SI*/DO** advancement, finalize immediately. If YES: transition to awaiting_uncapped_defensive_throw. + + Caller must hold the game lock (acquired by WebSocket handler). """ - 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") + 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") + 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, - }, + 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: @@ -1351,96 +1356,98 @@ class GameEngine: 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. + + Caller must hold the game lock (acquired by WebSocket handler). """ - 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") + 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") + 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}" - ) + 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 + 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") + 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) + 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( + league_id=state.league_id, + game_id=game_id, + team_id=state.get_fielding_team_id(), + player_id=None, + ) + pending.speed_check_d20 = d20.roll if hasattr(d20, 'roll') else 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 - # Defense throws → check for trail runner - has_trail = pending.trail_runner_base is not None + 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.roll if hasattr(d20, 'roll') else 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) - 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, + # Check if offensive team is AI + if state.is_batting_team_ai(): + advance = await ai_opponent.decide_uncapped_trail_advance( + state, pending ) - 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) + await self.submit_uncapped_trail_advance(game_id, advance) + return - # 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, - }, - ) + 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 @@ -1450,137 +1457,39 @@ class GameEngine: If NO: roll d20 for lead runner only, transition to awaiting_uncapped_safe_out. If YES: transition to awaiting_uncapped_throw_target. + + Caller must hold the game lock (acquired by WebSocket handler). """ - 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") + 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") + 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}" - ) + 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 + 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 + if not advance: + # Trail declines → roll d20 for lead runner d20 = dice_system.roll_d20( + league_id=state.league_id, game_id=game_id, team_id=state.get_fielding_team_id(), player_id=None, ) - pending.speed_check_d20 = d20 - pending.speed_check_runner = target - + pending.speed_check_d20 = d20.roll if hasattr(d20, 'roll') else 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) - # 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) @@ -1592,14 +1501,116 @@ class GameEngine: 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, + "d20_roll": d20.roll if hasattr(d20, 'roll') else 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. + + Caller must hold the game lock (acquired by WebSocket handler). + """ + 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( + league_id=state.league_id, + game_id=game_id, + team_id=state.get_fielding_team_id(), + player_id=None, + ) + pending.speed_check_d20 = d20.roll if hasattr(d20, 'roll') else 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.roll if hasattr(d20, 'roll') else 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 @@ -1609,36 +1620,37 @@ class GameEngine: Finalizes the uncapped hit play with the accumulated decisions. + Caller must hold the game lock (acquired by WebSocket handler). + 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") + 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") + 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 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}'") + if result not in ("safe", "out"): + raise ValueError(f"result must be 'safe' or 'out', got '{result}'") - pending.speed_check_result = result + pending.speed_check_result = result - ab_roll = state.pending_manual_roll - if not ab_roll: - raise ValueError("No pending manual roll found") + 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) + 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, @@ -1860,25 +1872,27 @@ class GameEngine: ) outs_recorded += 1 - # If trail runner is R1 and R1 attempted advance, batter-runner - # auto-advances regardless of R1's outcome + # Determine batter's final base position batter_base = pending.batter_base - if ( + if pending.trail_runner_base == 0: + # Trail IS the batter-runner + if checked_runner == "trail": + # Defense threw at batter-runner + 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) + elif pending.trail_advance and pending.throw_target is not None: + # Batter attempted advance, defense threw at lead instead + # Batter auto-advances to target (non-targeted runner is safe) + batter_base = pending.trail_target_base + elif ( pending.trail_runner_base == 1 and pending.trail_advance - and pending.trail_runner_base is not None ): + # Trail is R1 (not batter) and R1 attempted advance # 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 @@ -1922,8 +1936,9 @@ class GameEngine: Clears pending state, calls _finalize_play for DB write and state update. """ - # Clear pending uncapped hit + # Clear pending uncapped hit and the manual roll (one-time use) state.pending_uncapped_hit = None + state.pending_manual_roll = None state.pending_decision = None state.decision_phase = "idle" @@ -2417,11 +2432,42 @@ class GameEngine: # Note: We don't delete dice rolls from the rolls table - they're kept for auditing # and don't affect game state reconstruction - # 5. Clear in-memory roll tracking for this game + # 5. Recalculate scores from remaining plays and update games table. + # recover_game() reads scores from the games table, which are stale after + # play deletion. We must update them before recovery. + remaining_plays = await self.db_ops.get_plays(game_id) + home_score = 0 + away_score = 0 + for play in remaining_plays: + if play.half == "top": + away_score += play.runs_scored + else: + home_score += play.runs_scored + + if remaining_plays: + last_play = remaining_plays[-1] + current_inning = last_play.inning + current_half = last_play.half + else: + current_inning = 1 + current_half = "top" + + logger.info( + f"Recalculated scores after rollback: home={home_score}, away={away_score}" + ) + await self.db_ops.update_game_state( + game_id=game_id, + inning=current_inning, + half=current_half, + home_score=home_score, + away_score=away_score, + ) + + # 6. Clear in-memory roll tracking for this game if game_id in self._rolls_this_inning: del self._rolls_this_inning[game_id] - # 6. Recover game state by replaying remaining plays + # 7. Recover game state by replaying remaining plays logger.info(f"Recovering game state for {game_id}") new_state = await state_manager.recover_game(game_id) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index 43f8714..3b9ccc4 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -560,9 +560,20 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: # Using the old state reference would overwrite those updates! state = state_manager.get_state(game_id) if state: - # Clear pending roll only AFTER successful validation (one-time use) - state.pending_manual_roll = None - state_manager.update_state(game_id, state) + # Clear pending roll only if NOT entering an interactive workflow. + # Uncapped hit and x-check workflows need pending_manual_roll + # to build their final PlayResult when decisions complete. + interactive_decision_phases = { + "awaiting_x_check_result", + "awaiting_uncapped_lead_advance", + "awaiting_uncapped_defensive_throw", + "awaiting_uncapped_trail_advance", + "awaiting_uncapped_throw_target", + "awaiting_uncapped_safe_out", + } + if state.decision_phase not in interactive_decision_phases: + state.pending_manual_roll = None + state_manager.update_state(game_id, state) except GameValidationError as e: # Game engine validation error (e.g., missing hit location) await manager.emit_to_user( @@ -604,6 +615,30 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: }, ) + # Check if the outcome initiated an interactive workflow (x-check or uncapped hit). + # If so, skip play_resolved broadcast - the play isn't actually resolved yet. + # The decision_required event and game_state_update will drive the UI instead. + interactive_phases = { + "awaiting_x_check_result", + "awaiting_uncapped_lead_advance", + "awaiting_uncapped_defensive_throw", + "awaiting_uncapped_trail_advance", + "awaiting_uncapped_throw_target", + "awaiting_uncapped_safe_out", + } + updated_state = state_manager.get_state(game_id) + if updated_state and updated_state.decision_phase in interactive_phases: + logger.info( + f"Manual outcome initiated interactive workflow ({updated_state.decision_phase}) " + f"for game {game_id} - skipping play_resolved, broadcasting state update" + ) + await manager.broadcast_to_game( + str(game_id), + "game_state_update", + updated_state.model_dump(mode="json"), + ) + return + # Build play result data play_result_data = { "game_id": str(game_id), @@ -2119,18 +2154,22 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"}) return - await game_engine.submit_uncapped_lead_advance(game_id, advance) + async with state_manager.game_lock(game_id): + 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") - ) + # 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 asyncio.TimeoutError: + logger.error(f"Lock timeout in submit_uncapped_lead_advance for game {game_id}") + await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"}) 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"}) @@ -2177,17 +2216,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: 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) + async with state_manager.game_lock(game_id): + 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") - ) + 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 asyncio.TimeoutError: + logger.error(f"Lock timeout in submit_uncapped_defensive_throw for game {game_id}") + await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"}) 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"}) @@ -2234,17 +2277,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: await manager.emit_to_user(sid, "error", {"message": "Missing or invalid 'advance' (bool)"}) return - await game_engine.submit_uncapped_trail_advance(game_id, advance) + async with state_manager.game_lock(game_id): + 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") - ) + 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 asyncio.TimeoutError: + logger.error(f"Lock timeout in submit_uncapped_trail_advance for game {game_id}") + await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"}) 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"}) @@ -2292,17 +2339,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: ) return - await game_engine.submit_uncapped_throw_target(game_id, target) + async with state_manager.game_lock(game_id): + 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") - ) + 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 asyncio.TimeoutError: + logger.error(f"Lock timeout in submit_uncapped_throw_target for game {game_id}") + await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"}) 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"}) @@ -2350,17 +2401,21 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: ) return - await game_engine.submit_uncapped_safe_out(game_id, result) + async with state_manager.game_lock(game_id): + 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") - ) + 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 asyncio.TimeoutError: + logger.error(f"Lock timeout in submit_uncapped_safe_out for game {game_id}") + await manager.emit_to_user(sid, "error", {"message": "Server busy - please try again"}) 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"}) diff --git a/frontend-sba/components/Game/GamePlay.vue b/frontend-sba/components/Game/GamePlay.vue index e36226f..2def8cc 100644 --- a/frontend-sba/components/Game/GamePlay.vue +++ b/frontend-sba/components/Game/GamePlay.vue @@ -113,9 +113,15 @@ :outs="gameState?.outs ?? 0" :has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)" :dice-color="diceColor" + :user-team-id="myTeamId" @roll-dice="handleRollDice" @submit-outcome="handleSubmitOutcome" @dismiss-result="handleDismissResult" + @submit-uncapped-lead-advance="handleUncappedLeadAdvance" + @submit-uncapped-defensive-throw="handleUncappedDefensiveThrow" + @submit-uncapped-trail-advance="handleUncappedTrailAdvance" + @submit-uncapped-throw-target="handleUncappedThrowTarget" + @submit-uncapped-safe-out="handleUncappedSafeOut" /> @@ -183,9 +189,15 @@ :outs="gameState?.outs ?? 0" :has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)" :dice-color="diceColor" + :user-team-id="myTeamId" @roll-dice="handleRollDice" @submit-outcome="handleSubmitOutcome" @dismiss-result="handleDismissResult" + @submit-uncapped-lead-advance="handleUncappedLeadAdvance" + @submit-uncapped-defensive-throw="handleUncappedDefensiveThrow" + @submit-uncapped-trail-advance="handleUncappedTrailAdvance" + @submit-uncapped-throw-target="handleUncappedThrowTarget" + @submit-uncapped-safe-out="handleUncappedSafeOut" /> @@ -447,18 +459,27 @@ const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('conne // Determine which team the user controls // For demo/testing: user controls whichever team needs to act +const DEFENSIVE_PHASES = [ + 'awaiting_defensive', + 'awaiting_uncapped_defensive_throw', + 'awaiting_uncapped_throw_target', +] + const myTeamId = computed(() => { if (!gameState.value) return null + const phase = gameState.value.decision_phase + const isDefensivePhase = DEFENSIVE_PHASES.includes(phase) + // Return the team that currently needs to make a decision if (gameState.value.half === 'top') { // Top: away bats, home fields - return gameState.value.decision_phase === 'awaiting_defensive' + return isDefensivePhase ? gameState.value.home_team_id : gameState.value.away_team_id } else { // Bottom: home bats, away fields - return gameState.value.decision_phase === 'awaiting_defensive' + return isDefensivePhase ? gameState.value.away_team_id : gameState.value.home_team_id } @@ -576,6 +597,11 @@ const showGameplay = computed(() => { return true } + // Show for uncapped hit decisions (both teams see the wizard) + if (gameStore.needsUncappedDecision) { + return true + } + return gameState.value?.status === 'active' && isMyTurn.value && !needsDefensiveDecision.value && @@ -656,6 +682,32 @@ const handleStealAttemptsSubmit = (attempts: number[]) => { gameStore.setPendingStealAttempts(attempts) } +// Methods - Uncapped Hit Decision Tree +const handleUncappedLeadAdvance = (advance: boolean) => { + console.log('[GamePlay] Uncapped lead advance:', advance) + actions.submitUncappedLeadAdvance(advance) +} + +const handleUncappedDefensiveThrow = (willThrow: boolean) => { + console.log('[GamePlay] Uncapped defensive throw:', willThrow) + actions.submitUncappedDefensiveThrow(willThrow) +} + +const handleUncappedTrailAdvance = (advance: boolean) => { + console.log('[GamePlay] Uncapped trail advance:', advance) + actions.submitUncappedTrailAdvance(advance) +} + +const handleUncappedThrowTarget = (target: 'lead' | 'trail') => { + console.log('[GamePlay] Uncapped throw target:', target) + actions.submitUncappedThrowTarget(target) +} + +const handleUncappedSafeOut = (result: 'safe' | 'out') => { + console.log('[GamePlay] Uncapped safe/out:', result) + actions.submitUncappedSafeOut(result) +} + const handleToggleHold = (base: number) => { defensiveSetup.toggleHold(base) } diff --git a/frontend-sba/components/Gameplay/GameplayPanel.vue b/frontend-sba/components/Gameplay/GameplayPanel.vue index 09ddb73..0d8feea 100644 --- a/frontend-sba/components/Gameplay/GameplayPanel.vue +++ b/frontend-sba/components/Gameplay/GameplayPanel.vue @@ -78,6 +78,21 @@ + +
+ +
+
@@ -119,12 +134,13 @@ + + diff --git a/frontend-sba/composables/useGameActions.ts b/frontend-sba/composables/useGameActions.ts index ffe8054..739ac11 100644 --- a/frontend-sba/composables/useGameActions.ts +++ b/frontend-sba/composables/useGameActions.ts @@ -243,6 +243,90 @@ export function useGameActions(gameId?: string) { uiStore.showInfo('Submitting speed check result...', 2000) } + // ============================================================================ + // Uncapped Hit Decision Tree + // ============================================================================ + + /** + * Submit lead runner advance decision (offensive player) + */ + function submitUncappedLeadAdvance(advance: boolean) { + if (!validateConnection()) return + + console.log('[GameActions] Submitting uncapped lead advance:', advance) + + socket.value!.emit('submit_uncapped_lead_advance', { + game_id: currentGameId.value!, + advance, + }) + + uiStore.showInfo('Submitting lead runner decision...', 2000) + } + + /** + * Submit defensive throw decision (defensive player) + */ + function submitUncappedDefensiveThrow(willThrow: boolean) { + if (!validateConnection()) return + + console.log('[GameActions] Submitting uncapped defensive throw:', willThrow) + + socket.value!.emit('submit_uncapped_defensive_throw', { + game_id: currentGameId.value!, + will_throw: willThrow, + }) + + uiStore.showInfo('Submitting throw decision...', 2000) + } + + /** + * Submit trail runner advance decision (offensive player) + */ + function submitUncappedTrailAdvance(advance: boolean) { + if (!validateConnection()) return + + console.log('[GameActions] Submitting uncapped trail advance:', advance) + + socket.value!.emit('submit_uncapped_trail_advance', { + game_id: currentGameId.value!, + advance, + }) + + uiStore.showInfo('Submitting trail runner decision...', 2000) + } + + /** + * Submit throw target selection (defensive player) + */ + function submitUncappedThrowTarget(target: 'lead' | 'trail') { + if (!validateConnection()) return + + console.log('[GameActions] Submitting uncapped throw target:', target) + + socket.value!.emit('submit_uncapped_throw_target', { + game_id: currentGameId.value!, + target, + }) + + uiStore.showInfo('Submitting throw target...', 2000) + } + + /** + * Submit safe/out result (offensive player) + */ + function submitUncappedSafeOut(result: 'safe' | 'out') { + if (!validateConnection()) return + + console.log('[GameActions] Submitting uncapped safe/out:', result) + + socket.value!.emit('submit_uncapped_safe_out', { + game_id: currentGameId.value!, + result, + }) + + uiStore.showInfo('Submitting speed check result...', 2000) + } + // ============================================================================ // Substitution Actions // ============================================================================ @@ -449,6 +533,13 @@ export function useGameActions(gameId?: string) { submitDecideThrow, submitDecideResult, + // Uncapped hit decision tree + submitUncappedLeadAdvance, + submitUncappedDefensiveThrow, + submitUncappedTrailAdvance, + submitUncappedThrowTarget, + submitUncappedSafeOut, + // Substitutions submitSubstitution, diff --git a/frontend-sba/composables/useWebSocket.ts b/frontend-sba/composables/useWebSocket.ts index 7bd9824..9c38058 100644 --- a/frontend-sba/composables/useWebSocket.ts +++ b/frontend-sba/composables/useWebSocket.ts @@ -470,6 +470,16 @@ export function useWebSocket() { console.log('[WebSocket] Full gameState:', JSON.stringify(gameState, null, 2).slice(0, 500)) gameStore.setGameState(gameState) console.log('[WebSocket] After setGameState, store current_batter:', gameStore.currentBatter) + + // Clear interactive workflow data when the workflow has completed + if (!gameState.pending_uncapped_hit && gameStore.uncappedHitData) { + gameStore.clearUncappedHitData() + gameStore.clearDecisionPrompt() + } + if (!gameState.pending_x_check && gameStore.xCheckData) { + gameStore.clearXCheckData() + gameStore.clearDecisionPrompt() + } }) state.socketInstance.on('game_state_sync', (data) => { @@ -497,22 +507,39 @@ export function useWebSocket() { // ======================================== state.socketInstance.on('decision_required', (prompt) => { - console.log('[WebSocket] Decision required:', prompt.phase, 'type:', prompt.type) + console.log('[WebSocket] Decision required:', prompt.phase) gameStore.setDecisionPrompt(prompt) - // Handle x-check specific decision types - if (prompt.type === 'x_check_result' && prompt.data) { - console.log('[WebSocket] X-Check result decision, position:', prompt.data.position) - gameStore.setXCheckData(prompt.data) - } else if (prompt.type === 'decide_advance' && prompt.data) { - console.log('[WebSocket] DECIDE advance decision') - gameStore.setDecideData(prompt.data) - } else if (prompt.type === 'decide_throw' && prompt.data) { - console.log('[WebSocket] DECIDE throw decision') - gameStore.setDecideData(prompt.data) - } else if (prompt.type === 'decide_speed_check' && prompt.data) { - console.log('[WebSocket] DECIDE speed check decision') - gameStore.setDecideData(prompt.data) + // Route phase-specific data to appropriate store slots + if (prompt.data) { + switch (prompt.phase) { + // X-Check phases + case 'awaiting_x_check_result': + console.log('[WebSocket] X-Check result decision, position:', prompt.data.position) + gameStore.setXCheckData(prompt.data as any) + break + case 'awaiting_decide_advance': + console.log('[WebSocket] DECIDE advance decision') + gameStore.setDecideData(prompt.data as any) + break + case 'awaiting_decide_throw': + console.log('[WebSocket] DECIDE throw decision') + gameStore.setDecideData(prompt.data as any) + break + case 'awaiting_decide_result': + console.log('[WebSocket] DECIDE speed check decision') + gameStore.setDecideData(prompt.data as any) + break + // Uncapped hit phases + case 'awaiting_uncapped_lead_advance': + case 'awaiting_uncapped_defensive_throw': + case 'awaiting_uncapped_trail_advance': + case 'awaiting_uncapped_throw_target': + case 'awaiting_uncapped_safe_out': + console.log('[WebSocket] Uncapped hit decision:', prompt.phase) + gameStore.setUncappedHitData(prompt.data as any) + break + } } }) @@ -604,6 +631,7 @@ export function useWebSocket() { gameStore.clearPendingDecisions() gameStore.clearXCheckData() gameStore.clearDecideData() + gameStore.clearUncappedHitData() uiStore.showSuccess(data.description, 5000) }) diff --git a/frontend-sba/store/game.ts b/frontend-sba/store/game.ts index 184d35b..c5f8b86 100644 --- a/frontend-sba/store/game.ts +++ b/frontend-sba/store/game.ts @@ -20,6 +20,7 @@ import type { DecideAdvanceData, DecideThrowData, DecideSpeedCheckData, + UncappedHitData, } from '~/types' export const useGameStore = defineStore('game', () => { @@ -44,6 +45,9 @@ export const useGameStore = defineStore('game', () => { const xCheckData = ref(null) const decideData = ref(null) + // Uncapped hit workflow state + const uncappedHitData = ref(null) + // Decision state (local pending decisions before submission) const pendingDefensiveSetup = ref(null) const pendingOffensiveDecision = ref | null>(null) @@ -156,6 +160,39 @@ export const useGameStore = defineStore('game', () => { gameState.value?.decision_phase === 'awaiting_decide_result' }) + const needsUncappedLeadAdvance = computed(() => { + return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_lead_advance' || + gameState.value?.decision_phase === 'awaiting_uncapped_lead_advance' + }) + + const needsUncappedDefensiveThrow = computed(() => { + return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_defensive_throw' || + gameState.value?.decision_phase === 'awaiting_uncapped_defensive_throw' + }) + + const needsUncappedTrailAdvance = computed(() => { + return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_trail_advance' || + gameState.value?.decision_phase === 'awaiting_uncapped_trail_advance' + }) + + const needsUncappedThrowTarget = computed(() => { + return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_throw_target' || + gameState.value?.decision_phase === 'awaiting_uncapped_throw_target' + }) + + const needsUncappedSafeOut = computed(() => { + return currentDecisionPrompt.value?.phase === 'awaiting_uncapped_safe_out' || + gameState.value?.decision_phase === 'awaiting_uncapped_safe_out' + }) + + const needsUncappedDecision = computed(() => { + return needsUncappedLeadAdvance.value || + needsUncappedDefensiveThrow.value || + needsUncappedTrailAdvance.value || + needsUncappedThrowTarget.value || + needsUncappedSafeOut.value + }) + const canRollDice = computed(() => { return gameState.value?.decision_phase === 'resolution' && !pendingRoll.value }) @@ -388,6 +425,20 @@ export const useGameStore = defineStore('game', () => { decideData.value = null } + /** + * Set uncapped hit data (from decision_required event) + */ + function setUncappedHitData(data: UncappedHitData | null) { + uncappedHitData.value = data + } + + /** + * Clear uncapped hit data after resolution + */ + function clearUncappedHitData() { + uncappedHitData.value = null + } + /** * Reset game store (when leaving game) */ @@ -409,6 +460,7 @@ export const useGameStore = defineStore('game', () => { decisionHistory.value = [] xCheckData.value = null decideData.value = null + uncappedHitData.value = null } /** @@ -464,6 +516,7 @@ export const useGameStore = defineStore('game', () => { decisionHistory: readonly(decisionHistory), xCheckData: readonly(xCheckData), decideData: readonly(decideData), + uncappedHitData: readonly(uncappedHitData), // Getters gameId, @@ -496,6 +549,12 @@ export const useGameStore = defineStore('game', () => { needsDecideAdvance, needsDecideThrow, needsDecideResult, + needsUncappedLeadAdvance, + needsUncappedDefensiveThrow, + needsUncappedTrailAdvance, + needsUncappedThrowTarget, + needsUncappedSafeOut, + needsUncappedDecision, canRollDice, canSubmitOutcome, recentPlays, @@ -526,6 +585,8 @@ export const useGameStore = defineStore('game', () => { clearXCheckData, setDecideData, clearDecideData, + setUncappedHitData, + clearUncappedHitData, resetGame, getActiveLineup, getBenchPlayers, diff --git a/frontend-sba/types/game.ts b/frontend-sba/types/game.ts index 76f23bb..466ca89 100644 --- a/frontend-sba/types/game.ts +++ b/frontend-sba/types/game.ts @@ -49,6 +49,12 @@ export type DecisionPhase = | 'awaiting_decide_advance' | 'awaiting_decide_throw' | 'awaiting_decide_result' + // Uncapped hit decision tree phases + | 'awaiting_uncapped_lead_advance' + | 'awaiting_uncapped_defensive_throw' + | 'awaiting_uncapped_trail_advance' + | 'awaiting_uncapped_throw_target' + | 'awaiting_uncapped_safe_out' /** * Lineup player state - represents a player in the game @@ -138,6 +144,9 @@ export interface GameState { // Interactive x-check workflow pending_x_check: PendingXCheck | null + // Uncapped hit decision tree + pending_uncapped_hit: PendingUncappedHit | null + // Play history play_count: number last_play_result: string | null @@ -314,6 +323,7 @@ export interface DecisionPrompt { timeout_seconds: number options?: string[] message?: string + data?: Record } /** @@ -440,3 +450,98 @@ export interface PendingXCheck { decide_throw: string | null decide_d20: number | null } + +/** + * Pending Uncapped Hit State (on GameState) + * Persisted for reconnection recovery. + * Backend: PendingUncappedHit (game_models.py) + */ +export interface PendingUncappedHit { + hit_type: string + hit_location: string + lead_runner_base: number + lead_runner_lineup_id: number + lead_target_base: number + auto_runners: number[][] + lead_advance: boolean | null + will_throw: boolean | null + trail_runner_base: number | null + trail_runner_lineup_id: number | null + trail_target_base: number | null + trail_advance: boolean | null + throw_target: string | null + d20_roll: number | null + safe_out_result: string | null +} + +/** + * Phase 1: Lead runner advance decision data + * Sent with awaiting_uncapped_lead_advance + */ +export interface UncappedLeadAdvanceData { + hit_type: string + hit_location: string + lead_runner_base: number + lead_runner_lineup_id: number + lead_target_base: number + auto_runners: number[][] +} + +/** + * Phase 2: Defensive throw decision data + * Sent with awaiting_uncapped_defensive_throw + */ +export interface UncappedDefensiveThrowData { + lead_runner_base: number + lead_target_base: number + lead_runner_lineup_id: number + hit_location: string +} + +/** + * Phase 3: Trail runner advance decision data + * Sent with awaiting_uncapped_trail_advance + */ +export interface UncappedTrailAdvanceData { + trail_runner_base: number + trail_target_base: number + trail_runner_lineup_id: number + hit_location: string +} + +/** + * Phase 4: Throw target selection data + * Sent with awaiting_uncapped_throw_target + */ +export interface UncappedThrowTargetData { + lead_runner_base: number + lead_target_base: number + lead_runner_lineup_id: number + trail_runner_base: number + trail_target_base: number + trail_runner_lineup_id: number + hit_location: string +} + +/** + * Phase 5: Safe/out resolution data + * Sent with awaiting_uncapped_safe_out + */ +export interface UncappedSafeOutData { + d20_roll: number + runner: string + runner_base: number + target_base: number + runner_lineup_id: number + hit_location: string +} + +/** + * Union of all uncapped hit phase data types + */ +export type UncappedHitData = + | UncappedLeadAdvanceData + | UncappedDefensiveThrowData + | UncappedTrailAdvanceData + | UncappedThrowTargetData + | UncappedSafeOutData diff --git a/frontend-sba/types/index.ts b/frontend-sba/types/index.ts index df3b6c1..bc62610 100644 --- a/frontend-sba/types/index.ts +++ b/frontend-sba/types/index.ts @@ -36,6 +36,14 @@ export type { DecideThrowData, DecideSpeedCheckData, PendingXCheck, + // Uncapped hit workflow types + PendingUncappedHit, + UncappedLeadAdvanceData, + UncappedDefensiveThrowData, + UncappedTrailAdvanceData, + UncappedThrowTargetData, + UncappedSafeOutData, + UncappedHitData, } from './game' // Player types @@ -76,6 +84,12 @@ export type { SubmitDecideAdvanceRequest, SubmitDecideThrowRequest, SubmitDecideResultRequest, + // Uncapped hit workflow request types + SubmitUncappedLeadAdvanceRequest, + SubmitUncappedDefensiveThrowRequest, + SubmitUncappedTrailAdvanceRequest, + SubmitUncappedThrowTargetRequest, + SubmitUncappedSafeOutRequest, // Event types ConnectedEvent, GameJoinedEvent, diff --git a/frontend-sba/types/websocket.ts b/frontend-sba/types/websocket.ts index 1743654..c23144f 100644 --- a/frontend-sba/types/websocket.ts +++ b/frontend-sba/types/websocket.ts @@ -58,6 +58,13 @@ export interface ClientToServerEvents { submit_decide_throw: (data: SubmitDecideThrowRequest) => void submit_decide_result: (data: SubmitDecideResultRequest) => void + // Uncapped hit decision tree + submit_uncapped_lead_advance: (data: SubmitUncappedLeadAdvanceRequest) => void + submit_uncapped_defensive_throw: (data: SubmitUncappedDefensiveThrowRequest) => void + submit_uncapped_trail_advance: (data: SubmitUncappedTrailAdvanceRequest) => void + submit_uncapped_throw_target: (data: SubmitUncappedThrowTargetRequest) => void + submit_uncapped_safe_out: (data: SubmitUncappedSafeOutRequest) => void + // Substitutions request_pinch_hitter: (data: PinchHitterRequest) => void request_defensive_replacement: (data: DefensiveReplacementRequest) => void @@ -394,3 +401,28 @@ export interface SubmitDecideResultRequest { game_id: string outcome: 'safe' | 'out' } + +export interface SubmitUncappedLeadAdvanceRequest { + game_id: string + advance: boolean +} + +export interface SubmitUncappedDefensiveThrowRequest { + game_id: string + will_throw: boolean +} + +export interface SubmitUncappedTrailAdvanceRequest { + game_id: string + advance: boolean +} + +export interface SubmitUncappedThrowTargetRequest { + game_id: string + target: 'lead' | 'trail' +} + +export interface SubmitUncappedSafeOutRequest { + game_id: string + result: 'safe' | 'out' +} -- 2.25.1