# 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
