# Week 7 Implementation Plan - Strategic Decisions & Result Charts **Phase**: Phase 3 - Complete Game Features **Duration**: Week 7 (Est. 20-25 hours) **Status**: Not Started **Prerequisites**: Week 6 Complete (PlayOutcome enum, League configs, Player models) --- ## Overview Week 7 focuses on implementing the complete strategic decision system and full result charts for both leagues. This transforms the game from a simplified simulator into a feature-complete baseball strategy game. ## Goals By end of Week 7, you should have: - ✅ All strategic decisions integrated into game flow - ✅ Decision validation against game state - ✅ Complete result charts with situational outcomes - ✅ Hit location and runner advancement logic - ✅ Double play mechanics for GROUNDBALL_A outcomes - ✅ Uncapped hit decision trees (SINGLE_UNCAPPED, DOUBLE_UNCAPPED) - ✅ WebSocket handlers for decision submission - ✅ Terminal client support for all decision types --- ## Architecture Overview ### Current State (Week 6 Complete) **Already Implemented:** - `DefensiveDecision` model with validators (game_models.py:123-160) - `OffensiveDecision` model with validators (game_models.py:162-190) - `PlayOutcome` enum with 30+ granular variants (result_charts.py) - League configs (SbaConfig, PdConfig) with immutable settings - Play model supports strategic decisions JSON (defensive_choices, offensive_choices) - GameState tracks current batter/pitcher lineup IDs **What's Missing:** - Integration of decisions into play resolution - Decision validation in validators.py - Complete result charts (currently SimplifiedResultChart only) - Hit location and advancement logic - Double play mechanics - Uncapped hit decision workflows ### Target Architecture ``` User Decision → WebSocket Handler → Validator → GameEngine ↓ DecisionState stored ↓ Dice Roll → ResultChart ↓ PlayResolver (with decisions) ↓ PlayResult (affected by decisions) ↓ Apply to GameState ``` --- ## Implementation Tasks ### Task 1: Strategic Decision Integration (6-8 hours) #### 1.1 Enhance GameEngine Decision Workflow **File**: `backend/app/core/game_engine.py` **Current Flow**: ```python # STEP 1: Prepare next play await _prepare_next_play(state) # Missing: Await strategic decisions from both teams # Missing: Validate decisions against game state # STEP 2: Roll dice and resolve roll = self.dice.roll_ab(...) result = self.play_resolver.resolve(roll) ``` **Enhanced Flow**: ```python # STEP 1: Prepare next play snapshot await _prepare_next_play(state) # STEP 2: Await defensive decision defensive_decision = await _await_defensive_decision(state) state.pending_defensive_decision = defensive_decision # STEP 3: Await offensive decision offensive_decision = await _await_offensive_decision(state) state.pending_offensive_decision = offensive_decision # STEP 4: Validate decisions self.validators.validate_defensive_decision(state, defensive_decision) self.validators.validate_offensive_decision(state, offensive_decision) # STEP 5: Roll dice with decision context roll = self.dice.roll_ab(...) # STEP 6: Resolve with decisions applied result = self.play_resolver.resolve( roll=roll, state=state, defensive_decision=defensive_decision, offensive_decision=offensive_decision ) # STEP 7: Handle special outcomes (uncapped hits, steals, etc.) if result.outcome.is_uncapped(): await _handle_uncapped_hit(state, result) if offensive_decision.steal_attempts: await _resolve_steal_attempts(state, offensive_decision) ``` **New Methods to Add**: ```python async def _await_defensive_decision( self, state: GameState, timeout: int = 30 ) -> DefensiveDecision: """ Wait for defensive team to submit decision. For AI teams: Generate decision immediately For human teams: Wait for WebSocket submission (with timeout) Args: state: Current game state timeout: Seconds to wait before using default decision Returns: DefensiveDecision (validated) Raises: TimeoutError: If timeout exceeded (async games only) """ # Check if fielding team is AI if state.is_fielding_team_ai(): return await self.ai_opponent.generate_defensive_decision(state) # Wait for human decision via WebSocket decision = await self._wait_for_decision( game_id=state.game_id, team_id=state.get_fielding_team_id(), decision_type="defensive", timeout=timeout ) return decision async def _await_offensive_decision( self, state: GameState, timeout: int = 30 ) -> OffensiveDecision: """ Wait for offensive team to submit decision. Similar to _await_defensive_decision but for batting team. """ if state.is_batting_team_ai(): return await self.ai_opponent.generate_offensive_decision(state) decision = await self._wait_for_decision( game_id=state.game_id, team_id=state.get_batting_team_id(), decision_type="offensive", timeout=timeout ) return decision async def _wait_for_decision( self, game_id: UUID, team_id: int, decision_type: str, timeout: int ) -> Union[DefensiveDecision, OffensiveDecision]: """ Generic decision waiting with timeout. Uses asyncio.wait_for with timeout. Emits WebSocket event to notify frontend. Returns: Decision object (when received) Raises: TimeoutError: If no decision within timeout """ # Store pending decision request self.state_manager.set_pending_decision( game_id=game_id, team_id=team_id, decision_type=decision_type ) # Emit WebSocket notification await self.connection_manager.emit_decision_required( game_id=game_id, team_id=team_id, decision_type=decision_type, timeout=timeout, game_situation=state.to_situation_summary() ) # Wait for decision with timeout try: decision = await asyncio.wait_for( self.state_manager.await_decision(game_id, team_id), timeout=timeout ) return decision except asyncio.TimeoutError: # Use default decision logger.warning(f"Decision timeout for game {game_id}, using default") return self._get_default_decision(decision_type) ``` **Decision Storage in GameState**: Add to `GameState` model in `game_models.py`: ```python class GameState(BaseModel): # ... existing fields ... # Pending decisions for current play pending_defensive_decision: Optional[DefensiveDecision] = None pending_offensive_decision: Optional[OffensiveDecision] = None # Decision phase tracking decision_phase: str = "awaiting_defensive" # awaiting_defensive, awaiting_offensive, resolving decision_deadline: Optional[pendulum.DateTime] = None ``` #### 1.2 Enhance StateManager Decision Handling **File**: `backend/app/core/state_manager.py` **Add Decision Queue**: ```python class StateManager: def __init__(self): self._states: Dict[UUID, GameState] = {} self._lineups: Dict[UUID, Dict[int, TeamLineupState]] = {} self._pending_decisions: Dict[UUID, asyncio.Future] = {} # NEW def set_pending_decision( self, game_id: UUID, team_id: int, decision_type: str ) -> None: """Mark that a decision is required.""" key = (game_id, team_id, decision_type) self._pending_decisions[key] = asyncio.Future() async def await_decision( self, game_id: UUID, team_id: int ) -> Union[DefensiveDecision, OffensiveDecision]: """Wait for a decision to be submitted.""" # Find pending decision future for key, future in self._pending_decisions.items(): if key[0] == game_id and key[1] == team_id: return await future raise ValueError(f"No pending decision for game {game_id}, team {team_id}") def submit_decision( self, game_id: UUID, team_id: int, decision: Union[DefensiveDecision, OffensiveDecision] ) -> None: """Submit a decision (called by WebSocket handler).""" # Find and resolve the pending future for key, future in list(self._pending_decisions.items()): if key[0] == game_id and key[1] == team_id: if not future.done(): future.set_result(decision) del self._pending_decisions[key] return raise ValueError(f"No pending decision for game {game_id}, team {team_id}") ``` **Acceptance Criteria**: - [ ] GameEngine workflow includes decision phases - [ ] StateManager manages decision futures - [ ] Timeout handling works (default decisions applied) - [ ] AI teams generate decisions immediately - [ ] Human teams wait for WebSocket submission --- ### Task 2: Decision Validators (3-4 hours) **File**: `backend/app/core/validators.py` **Current State**: Has basic validation for outs, innings, lineups **Target**: Add strategic decision validation #### 2.1 Defensive Decision Validation ```python def validate_defensive_decision( state: GameState, decision: DefensiveDecision ) -> None: """ Validate defensive decision against game state. Raises: ValueError: If decision is invalid for current situation """ # Validate hold runners for base in decision.hold_runners: if base not in [1, 2, 3]: raise ValueError(f"Cannot hold runner on base {base}") # Check if runner exists on that base runner = state.get_runner_at_base(base) if runner is None: raise ValueError(f"Cannot hold runner on empty base {base}") # Validate infield depth vs situation if decision.infield_depth == "double_play": if state.outs >= 2: raise ValueError("Cannot play for double play with 2 outs") if not state.is_runner_on_first(): raise ValueError("Cannot play for double play without runner on first") # Validate infield in if decision.infield_depth == "in": if state.outs >= 2: # Warning but allow (sometimes done in desperation) logger.warning("Infield in with 2 outs - risky play") ``` #### 2.2 Offensive Decision Validation ```python def validate_offensive_decision( state: GameState, decision: OffensiveDecision ) -> None: """ Validate offensive decision against game state. Raises: ValueError: If decision is invalid for current situation """ # Validate steal attempts for base in decision.steal_attempts: stealing_from = base - 1 if stealing_from not in [1, 2, 3]: raise ValueError(f"Invalid steal attempt to base {base}") runner = state.get_runner_at_base(stealing_from) if runner is None: raise ValueError(f"No runner on base {stealing_from} to steal") # Validate bunt attempt if decision.bunt_attempt: if state.outs >= 2: raise ValueError("Cannot bunt with 2 outs") if decision.hit_and_run: raise ValueError("Cannot bunt and hit-and-run simultaneously") # Validate hit and run if decision.hit_and_run: if not any(state.get_runner_at_base(b) for b in [1, 2, 3]): raise ValueError("Hit and run requires at least one runner on base") ``` **Acceptance Criteria**: - [ ] All defensive decision rules validated - [ ] All offensive decision rules validated - [ ] Clear error messages for invalid decisions - [ ] Edge cases handled (e.g., bunt with 2 outs) --- ### Task 3: Complete Result Charts (8-10 hours) **File**: `backend/app/config/result_charts.py` **Current State**: Has `SimplifiedResultChart` with basic d20 distribution **Target**: Complete result chart system with situational modifiers #### 3.1 Enhanced Result Chart Interface ```python from abc import ABC, abstractmethod from app.models.game_models import DefensiveDecision, OffensiveDecision class ResultChart(ABC): """Base class for result chart implementations.""" @abstractmethod def get_outcome( self, roll: AbRoll, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision ) -> PlayOutcome: """ Determine play outcome from roll and game situation. Args: roll: Dice roll result state: Current game state defensive_decision: Defensive team's decisions offensive_decision: Offensive team's decisions Returns: PlayOutcome enum value """ pass @abstractmethod def get_hit_location( self, outcome: PlayOutcome, batter_handedness: str, # 'L' or 'R' defensive_alignment: str ) -> str: """ Determine where the ball was hit. Returns: Hit location code (e.g., "7" = LF, "8" = CF, "9" = RF) """ pass ``` #### 3.2 Standard Result Chart (SBA League) ```python class StandardResultChart(ResultChart): """ Standard result chart for SBA league. Based on simplified Strat-O-Matic mechanics with defensive modifiers. """ def get_outcome( self, roll: AbRoll, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision ) -> PlayOutcome: """ Resolve outcome with strategic decision modifiers. """ resolution_d20 = roll.resolution_d20 # Base outcome from roll outcome = self._get_base_outcome(resolution_d20) # Apply defensive modifiers outcome = self._apply_defensive_modifiers( outcome=outcome, decision=defensive_decision, state=state ) # Apply offensive modifiers outcome = self._apply_offensive_modifiers( outcome=outcome, decision=offensive_decision, state=state ) return outcome def _get_base_outcome(self, roll: int) -> PlayOutcome: """Base outcome distribution.""" if roll <= 2: return PlayOutcome.STRIKEOUT elif roll <= 5: return PlayOutcome.WALK elif roll <= 8: # Groundball variants return self._get_groundball_variant(roll) elif roll <= 11: # Flyout variants return self._get_flyout_variant(roll) elif roll <= 13: return PlayOutcome.WALK elif roll <= 15: # Single variants return self._get_single_variant(roll) elif roll <= 17: # Double variants return self._get_double_variant(roll) elif roll == 18: return PlayOutcome.LINEOUT elif roll == 19: return PlayOutcome.TRIPLE else: # roll == 20 return PlayOutcome.HOMERUN def _get_groundball_variant(self, roll: int) -> PlayOutcome: """Determine groundball type.""" if roll == 6: return PlayOutcome.GROUNDBALL_A # DP opportunity elif roll == 7: return PlayOutcome.GROUNDBALL_B # Standard else: # roll == 8 return PlayOutcome.GROUNDBALL_C # Slow roller def _apply_defensive_modifiers( self, outcome: PlayOutcome, decision: DefensiveDecision, state: GameState ) -> PlayOutcome: """ Modify outcome based on defensive positioning. Examples: - Infield in: GROUNDBALL_B → GROUNDOUT (faster plays) - Infield back: GROUNDBALL_A → GROUNDBALL_B (sacrifice DP for out) - Shifted: More likely to convert GB to outs on pull side """ # Infield depth modifiers if decision.infield_depth == "in": # Better chance to get lead runner, but harder DP if outcome == PlayOutcome.GROUNDBALL_A: return PlayOutcome.GROUNDBALL_B # Sacrifice DP elif decision.infield_depth == "double_play": # Optimized for turning two if outcome == PlayOutcome.GROUNDBALL_B: return PlayOutcome.GROUNDBALL_A # Upgrade to DP chance # Shift modifiers if decision.alignment != "normal": # Shifts make pull-side grounders more likely to be outs # But give up hits on opposite side # TODO: Implement based on hit location pass return outcome def _apply_offensive_modifiers( self, outcome: PlayOutcome, decision: OffensiveDecision, state: GameState ) -> PlayOutcome: """ Modify outcome based on offensive approach. Examples: - Contact approach: Fewer strikeouts, more weak contact - Power approach: More strikeouts, more extra-base hits - Bunt: Convert normal swing to bunt outcome """ if decision.bunt_attempt: # Convert to bunt outcome if contact made if outcome.is_hit() or outcome in [ PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C ]: return PlayOutcome.BUNT_GROUNDOUT # TODO: Add to PlayOutcome enum if decision.approach == "contact": # Reduce strikeouts, increase contact outs if outcome == PlayOutcome.STRIKEOUT: # 50% chance to convert to weak contact if random.random() < 0.5: return PlayOutcome.GROUNDBALL_C elif decision.approach == "power": # More extra bases, but more strikeouts if outcome == PlayOutcome.SINGLE_1: # 20% chance to upgrade to double if random.random() < 0.2: return PlayOutcome.DOUBLE_2 return outcome def get_hit_location( self, outcome: PlayOutcome, batter_handedness: str, defensive_alignment: str ) -> str: """ Determine hit location using probability distribution. Location codes: - "7": Left field - "8": Center field - "9": Right field - "5": Third base - "6": Shortstop - "4": Second base - "3": First base """ # Default pull rates by handedness if batter_handedness == "R": # RHB pulls to left pull_rate = 0.45 center_rate = 0.35 oppo_rate = 0.20 else: # LHB # LHB pulls to right pull_rate = 0.45 center_rate = 0.35 oppo_rate = 0.20 # Modify based on defensive shift if defensive_alignment == "shifted_left": # Defense shifted to pull side (left for RHB) pull_rate -= 0.15 # Harder to pull oppo_rate += 0.15 # More opposite field elif defensive_alignment == "shifted_right": pull_rate -= 0.15 oppo_rate += 0.15 # Roll for location roll = random.random() if outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]: # Ground ball locations (infield) if batter_handedness == "R": if roll < pull_rate: return "5" if roll < pull_rate/2 else "6" # 3B or SS elif roll < pull_rate + center_rate: return "4" if roll < pull_rate + center_rate/2 else "6" # 2B or SS else: return "3" # 1B else: # LHB if roll < pull_rate: return "4" if roll < pull_rate/2 else "3" # 2B or 1B elif roll < pull_rate + center_rate: return "6" if roll < pull_rate + center_rate/2 else "4" # SS or 2B else: return "5" # 3B else: # Fly ball / line drive locations (outfield) if batter_handedness == "R": if roll < pull_rate: return "7" # LF elif roll < pull_rate + center_rate: return "8" # CF else: return "9" # RF else: # LHB if roll < pull_rate: return "9" # RF elif roll < pull_rate + center_rate: return "8" # CF else: return "7" # LF ``` #### 3.3 PD Result Chart (Card-Based) ```python class PdResultChart(ResultChart): """ PD league result chart using player card ratings. Resolves outcomes from batting/pitching card probabilities. """ def get_outcome( self, roll: AbRoll, state: GameState, defensive_decision: DefensiveDecision, offensive_decision: OffensiveDecision ) -> PlayOutcome: """ Resolve from player cards with column selection. 1. Roll offense_d6 (1-3: batter card, 4-6: pitcher card) 2. Get rating from selected card 3. Roll 2d6 to select outcome row (2-12) 4. Roll split_d20 if needed for resolution """ # Determine which card to use use_batter_card = roll.offense_d6 <= 3 # Get appropriate ratings if use_batter_card: rating = self._get_batter_rating(state) else: rating = self._get_pitcher_rating(state) if rating is None: # Fallback to simplified chart logger.warning("No card data available, using simplified chart") return SimplifiedResultChart().get_outcome(roll, state, defensive_decision, offensive_decision) # Convert 2d6 roll to outcome card_roll = roll.offense_d6 # Reuse as card row selector outcome = self._get_outcome_from_rating( rating=rating, card_roll=card_roll, split_roll=roll.split_d20 ) # Apply decision modifiers (same as StandardResultChart) outcome = self._apply_defensive_modifiers(outcome, defensive_decision, state) outcome = self._apply_offensive_modifiers(outcome, offensive_decision, state) return outcome def _get_batter_rating( self, state: GameState ) -> Optional[PdBattingRating]: """Get batter's rating vs pitcher handedness.""" # Get current batter from lineup batter = state.get_current_batter() if not batter or not isinstance(batter.player_data, PdPlayer): return None # Get pitcher handedness pitcher = state.get_current_pitcher() pitcher_hand = pitcher.player_data.hand if pitcher else 'R' # Return appropriate rating return batter.player_data.get_batting_rating(pitcher_hand) def _get_outcome_from_rating( self, rating: Union[PdBattingRating, PdPitchingRating], card_roll: int, split_roll: int ) -> PlayOutcome: """ Convert card rating probabilities to PlayOutcome. Uses cumulative probability distribution to select outcome. """ # Build cumulative distribution cumulative = 0.0 roll_pct = split_roll / 20.0 # Convert 1-20 to 0.05-1.0 outcomes = [ (rating.homerun, PlayOutcome.HOMERUN), (rating.triple, PlayOutcome.TRIPLE), (rating.double_3, PlayOutcome.DOUBLE_3), (rating.double_2, PlayOutcome.DOUBLE_2), (rating.single_2, PlayOutcome.SINGLE_2), (rating.single_1, PlayOutcome.SINGLE_1), (rating.walk, PlayOutcome.WALK), (rating.strikeout, PlayOutcome.STRIKEOUT), # ... all other outcomes ] for probability, outcome in outcomes: cumulative += probability if roll_pct <= cumulative: return outcome # Default fallback return PlayOutcome.GROUNDBALL_B ``` #### 3.4 Runner Advancement Logic ```python class RunnerAdvancer: """ Handles runner advancement for different play outcomes. """ def advance_runners( self, state: GameState, outcome: PlayOutcome, hit_location: str, outs_recorded: int ) -> List[RunnerMovement]: """ Calculate runner advancement for a play. Returns: List of RunnerMovement (from_base, to_base, lineup_id) """ movements = [] # Get runners in reverse order (3rd, 2nd, 1st) for base in [3, 2, 1]: runner = state.get_runner_at_base(base) if runner is None: continue destination = self._calculate_destination( outcome=outcome, from_base=base, hit_location=hit_location, outs_recorded=outs_recorded, state=state ) movements.append(RunnerMovement( lineup_id=runner.lineup_id, from_base=base, to_base=destination )) return movements def _calculate_destination( self, outcome: PlayOutcome, from_base: int, hit_location: str, outs_recorded: int, state: GameState ) -> int: """ Calculate where a runner advances to. Returns: Base number (1-3) or 4 (home/scored) or 0 (out) """ # Forced advances (walks, hit by pitch) if outcome == PlayOutcome.WALK: if from_base == 1 or state.bases_are_loaded(): return from_base + 1 else: return from_base # Stay put # Singles if outcome in [PlayOutcome.SINGLE_1, PlayOutcome.SINGLE_2]: if from_base == 3: return 4 # Score from third elif from_base == 2: if outcome == PlayOutcome.SINGLE_2: return 4 # Score from second on enhanced single else: return 3 # Third on standard single else: # from_base == 1 if outcome == PlayOutcome.SINGLE_2: return 3 # Extra base else: return 2 # Standard # Doubles if outcome in [PlayOutcome.DOUBLE_2, PlayOutcome.DOUBLE_3]: if from_base >= 2: return 4 # Score from 2nd or 3rd else: # from_base == 1 if outcome == PlayOutcome.DOUBLE_3: return 4 # Score from first (rare) else: return 3 # Third on standard double # Triples if outcome == PlayOutcome.TRIPLE: return 4 # All runners score # Homeruns if outcome == PlayOutcome.HOMERUN: return 4 # All runners score # Groundouts (runner may or may not advance) if outcome in [PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C]: if outs_recorded == 0: # No outs made, runners likely forced or held if from_base == 3: return 4 # Score on contact elif self._is_force_play(from_base, state): return from_base + 1 else: return from_base # Hold else: # Outs recorded if from_base == 3 and outs_recorded == 1: return 4 # Score on groundout (sac) else: return from_base # Hold # Flyouts if outcome in [PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_C]: if outs_recorded > 0: # Tag up rules if from_base == 3: if outcome == PlayOutcome.FLYOUT_B: # Medium depth return 4 # Tag from third elif from_base == 2: if outcome == PlayOutcome.FLYOUT_C: # Deep fly return 3 # Tag to third return from_base # Hold if no tag # Default: stay put return from_base ``` **Acceptance Criteria**: - [ ] StandardResultChart with defensive/offensive modifiers - [ ] PdResultChart using player card ratings - [ ] Hit location logic with pull/center/opposite distribution - [ ] Runner advancement rules for all outcome types - [ ] Double play mechanics for GROUNDBALL_A - [ ] Tests for all result chart scenarios --- ### Task 4: WebSocket Handlers (3-4 hours) **File**: `backend/app/websocket/handlers.py` #### 4.1 Decision Submission Handlers ```python @sio.event async def submit_defensive_decision(sid: str, data: dict): """ Handle defensive decision submission. Expected data: { "game_id": "uuid", "team_id": 1, "alignment": "normal", "infield_depth": "normal", "outfield_depth": "normal", "hold_runners": [] } """ try: # Validate data game_id = UUID(data["game_id"]) team_id = int(data["team_id"]) # Create decision object decision = DefensiveDecision( alignment=data.get("alignment", "normal"), infield_depth=data.get("infield_depth", "normal"), outfield_depth=data.get("outfield_depth", "normal"), hold_runners=data.get("hold_runners", []) ) # Validate against game state state = state_manager.get_state(game_id) if state is None: await sio.emit('error', { "message": f"Game {game_id} not found" }, room=sid) return # Validate it's this team's turn if state.get_fielding_team_id() != team_id: await sio.emit('error', { "message": "Not your turn to make defensive decision" }, room=sid) return # Validate decision validators.validate_defensive_decision(state, decision) # Submit decision (resolves pending future) state_manager.submit_decision(game_id, team_id, decision) # Acknowledge await sio.emit('decision_accepted', { "type": "defensive", "team_id": team_id }, room=sid) # Broadcast to game room await connection_manager.broadcast_to_game( game_id, 'defensive_decision_submitted', {"team_id": team_id} ) except ValidationError as e: await sio.emit('error', { "message": f"Invalid decision: {str(e)}" }, room=sid) except Exception as e: logger.error(f"Error handling defensive decision: {e}", exc_info=True) await sio.emit('error', { "message": "Internal server error" }, room=sid) @sio.event async def submit_offensive_decision(sid: str, data: dict): """ Handle offensive decision submission. Expected data: { "game_id": "uuid", "team_id": 1, "approach": "normal", "steal_attempts": [], "hit_and_run": false, "bunt_attempt": false } """ # Similar implementation to submit_defensive_decision pass ``` **Acceptance Criteria**: - [ ] WebSocket handlers for both decision types - [ ] Validation before submission - [ ] Error handling with clear messages - [ ] Broadcast to game room on submission --- ### Task 5: Terminal Client Enhancement (2-3 hours) **File**: `backend/terminal_client/commands.py` **Current State**: Has basic defensive/offensive commands **Target**: Support all decision options #### 5.1 Enhanced Command Options ```python def submit_defensive_decision( engine: GameEngine, state: GameState, alignment: str = "normal", infield_depth: str = "normal", outfield_depth: str = "normal", hold_runners: List[int] = None ) -> None: """ Submit defensive decision with full options. Examples: defensive normal normal normal defensive shifted_left double_play normal --hold 1,3 defensive extreme_shift in back """ decision = DefensiveDecision( alignment=alignment, infield_depth=infield_depth, outfield_depth=outfield_depth, hold_runners=hold_runners or [] ) # Store in state state.pending_defensive_decision = decision print(f"✓ Defensive decision: {alignment}, IF: {infield_depth}, OF: {outfield_depth}") if hold_runners: print(f" Holding runners: {', '.join(str(b) for b in hold_runners)}") ``` #### 5.2 Enhanced Help Text ```python DEFENSIVE_HELP = """ Submit defensive decision for the current play. Usage: defensive [alignment] [infield_depth] [outfield_depth] [--hold BASES] Alignment Options: normal Standard positioning (default) shifted_left Shift defense to left side shifted_right Shift defense to right side extreme_shift Maximum shift (PD only) Infield Depth: in Infield in (prevent run from scoring) normal Standard depth (default) back Playing back (more range, less arm strength needed) double_play Optimized for turning two Outfield Depth: in Shallow (prevent sacrifice fly) normal Standard depth (default) back Deep (prevent extra bases) Examples: defensive # All defaults defensive shifted_left double_play normal defensive normal in normal --hold 1,3 """ ``` **Acceptance Criteria**: - [ ] Terminal client supports all decision options - [ ] Help text documents all options - [ ] Validation errors shown clearly --- ### Task 6: Double Play Mechanics (2-3 hours) **File**: `backend/app/core/play_resolver.py` #### 6.1 Double Play Resolution ```python def _resolve_double_play_attempt( self, state: GameState, hit_location: str, defensive_decision: DefensiveDecision ) -> Tuple[int, List[int]]: # (outs_recorded, runners_out_lineup_ids) """ Resolve double play attempt on GROUNDBALL_A. Factors: - Defensive positioning (double_play depth increases success) - Runner speed on first - Hit location (up the middle easier than to corners) - Number of outs already Returns: (outs_recorded, [lineup_ids of runners thrown out]) """ if state.outs >= 2: # Can't turn DP with 2 outs return (1, []) if not state.is_runner_on_first(): # Need runner on first for DP return (1, []) # Base DP probability dp_probability = 0.45 # 45% chance # Modify based on defensive positioning if defensive_decision.infield_depth == "double_play": dp_probability += 0.20 # 65% with DP depth elif defensive_decision.infield_depth == "back": dp_probability -= 0.15 # 30% playing back # Modify based on hit location if hit_location in ["4", "6"]: # Up the middle dp_probability += 0.10 elif hit_location in ["3", "5"]: # Corners dp_probability -= 0.10 # Modify based on runner speed (TODO: use player ratings) runner_on_first = state.on_first if runner_on_first and hasattr(runner_on_first.player_data, 'speed'): speed = runner_on_first.player_data.speed if speed >= 15: # Fast runner dp_probability -= 0.15 elif speed <= 5: # Slow runner dp_probability += 0.10 # Roll for DP if random.random() < dp_probability: # Double play! return (2, [runner_on_first.lineup_id, state.current_batter_lineup_id]) else: # Only one out (force at second or first) return (1, [runner_on_first.lineup_id]) # Runner forced at second ``` **Acceptance Criteria**: - [ ] Double play logic considers all factors - [ ] Defensive positioning affects DP probability - [ ] Runner speed affects outcome (when ratings available) - [ ] Hit location affects DP chance --- ## Testing Strategy ### Unit Tests **New Test Files**: 1. `tests/unit/core/test_result_charts.py` (40+ tests) - StandardResultChart outcomes - PdResultChart card-based resolution - Hit location distribution - Runner advancement rules - Double play mechanics 2. `tests/unit/core/test_decision_validators.py` (20+ tests) - Defensive decision validation - Offensive decision validation - Edge cases (2 outs, no runners, etc.) 3. `tests/unit/websocket/test_decision_handlers.py` (15+ tests) - Decision submission - Validation errors - Turn enforcement - Timeout handling ### Integration Tests **New Test Files**: 1. `tests/integration/test_strategic_gameplay.py` (10+ tests) - Complete play with strategic decisions - Decision timeout with default - AI opponent decision generation - WebSocket decision flow ### Terminal Client Testing **Manual Test Scenarios**: 1. Play with infield in, runner scores from third 2. Play for double play, DP successful 3. Steal attempt with different leads 4. Bunt attempt execution 5. Hit and run play --- ## Deliverables ### Code Files **New Files**: - `backend/app/core/runner_advancer.py` - Runner advancement logic - `backend/app/core/ai_opponent.py` - AI decision generation (stub for Week 9) - `backend/tests/unit/core/test_result_charts.py` - `backend/tests/unit/core/test_decision_validators.py` - `backend/tests/unit/websocket/test_decision_handlers.py` - `backend/tests/integration/test_strategic_gameplay.py` **Modified Files**: - `backend/app/core/game_engine.py` - Decision workflow integration - `backend/app/core/state_manager.py` - Decision queue management - `backend/app/core/validators.py` - Strategic decision validators - `backend/app/core/play_resolver.py` - Enhanced resolution with decisions - `backend/app/config/result_charts.py` - Complete result charts - `backend/app/models/game_models.py` - GameState decision fields - `backend/app/websocket/handlers.py` - Decision submission handlers - `backend/terminal_client/commands.py` - Enhanced decision commands - `backend/terminal_client/help_text.py` - Updated help documentation ### Documentation **Updated Files**: - `.claude/implementation/00-index.md` - Mark Week 7 complete - `.claude/implementation/NEXT_SESSION.md` - Week 8 plan - `backend/CLAUDE.md` - Document decision system ### Test Coverage **Target**: 90%+ coverage for new code **Test Counts**: - Unit tests: 75+ new tests - Integration tests: 10+ new tests - Total: 85+ new tests --- ## Success Criteria Week 7 is **complete** when: - ✅ All strategic decisions integrated into game flow - ✅ Decision validation enforces game rules - ✅ Complete result charts with situational modifiers - ✅ Hit location and runner advancement working - ✅ Double play mechanics functional - ✅ WebSocket handlers accept and validate decisions - ✅ Terminal client supports all decision types - ✅ 85+ new tests passing - ✅ AI opponent stubs ready for Week 9 (generate_defensive_decision, generate_offensive_decision) - ✅ Documentation updated --- ## Risks & Mitigation ### Risk 1: Decision Workflow Complexity **Impact**: High **Mitigation**: Start with synchronous flow (both decisions submitted before resolution), add async support later ### Risk 2: Result Chart Balance **Impact**: Medium **Mitigation**: Use SimplifiedResultChart as baseline, test extensively with real gameplay ### Risk 3: WebSocket State Management **Impact**: High **Mitigation**: Use asyncio.Future for decision awaiting, add timeout handling from start ### Risk 4: Runner Advancement Edge Cases **Impact**: Medium **Mitigation**: Implement conservative advancement rules, document TODOs for complex scenarios --- ## Week 8 Preview After Week 7, focus shifts to: 1. Substitution system (pinch hitters, defensive replacements) 2. Pitching changes (bullpen management) 3. Frontend game interface (mobile-first) 4. Decision workflows UI --- ## Quick Reference **Key Files to Modify**: 1. `app/core/game_engine.py` - Decision workflow 2. `app/core/validators.py` - Decision validation 3. `app/config/result_charts.py` - Result charts 4. `app/websocket/handlers.py` - WebSocket handlers 5. `terminal_client/commands.py` - Terminal client **Key Imports**: ```python from app.models.game_models import DefensiveDecision, OffensiveDecision, GameState from app.config import PlayOutcome, get_league_config from app.core.dice import AbRoll from app.core.validators import validate_defensive_decision, validate_offensive_decision ``` **Test Commands**: ```bash # Run all Week 7 tests pytest tests/unit/core/test_result_charts.py -v pytest tests/unit/core/test_decision_validators.py -v pytest tests/integration/test_strategic_gameplay.py -v # Terminal client testing python -m terminal_client > new_game > defensive shifted_left double_play normal > offensive normal --steal 2 > resolve ``` --- **Estimated Completion**: 20-25 hours **Priority**: High (blocks Week 8 frontend work) **Dependencies**: Week 6 Complete (✅) **Next Milestone**: Week 8 - Substitutions + Frontend UI