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 @@ + +