strat-gameplay-webapp/.claude/archive/WEEK_7_PLAN.md
Cal Corum c4e051c4a9 Documentation Archival
Moving unneeded notes to archive directory.
2025-11-01 01:17:15 -05:00

41 KiB

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:

# 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:

# 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:

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:

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:

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

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

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

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)

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)

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

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

@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

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

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

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:

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:

# 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