strat-gameplay-webapp/.claude/implementation/phase-3d-runner-advancement.md
Cal Corum a1f42a93b8 CLAUDE: Implement Phase 3A - X-Check data models and enums
Add foundational data structures for X-Check play resolution system:

Models Added:
- PositionRating: Defensive ratings (range 1-5, error 0-88) for X-Check resolution
- XCheckResult: Dataclass tracking complete X-Check resolution flow with dice rolls,
  conversions (SPD test, G2#/G3#→SI2), error results, and final outcomes
- BasePlayer.active_position_rating: Optional field for current defensive position

Enums Extended:
- PlayOutcome.X_CHECK: New outcome type requiring special resolution
- PlayOutcome.is_x_check(): Helper method for type checking

Documentation Enhanced:
- Play.check_pos: Documented as X-Check position identifier
- Play.hit_type: Documented with examples (single_2_plus_error_1, etc.)

Utilities Added:
- app/core/cache.py: Redis cache key helpers for player positions and game state

Implementation Planning:
- Complete 6-phase implementation plan (3A-3F) documented in .claude/implementation/
- Phase 3A complete with all acceptance criteria met
- Zero breaking changes, all existing tests passing

Next: Phase 3B will add defense tables, error charts, and advancement logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 15:32:09 -05:00

19 KiB

Phase 3D: X-Check Runner Advancement Tables

Status: Not Started Estimated Effort: 6-8 hours (table-heavy) Dependencies: Phase 3C (Resolution Logic)

Overview

Implement complete runner advancement tables for all X-Check result types. Each combination of (base_result, error_result, on_base_code, defender_in) has specific advancement rules.

This phase involves:

  • Groundball advancement (G1, G2, G3) with defender_in and error variations
  • Flyball advancement (F1, F2, F3) with error variations
  • Hit advancement (SI1, SI2, DO2, DO3, TR3) with error bonuses
  • Out advancement (FO, PO) with error overrides

Tasks

1. Create X-Check Advancement Tables Module

File: backend/app/core/x_check_advancement_tables.py (NEW FILE)

"""
X-Check runner advancement tables.

Each X-Check result type has specific advancement rules based on:
- on_base_code: Current runner configuration
- defender_in: Whether defender was playing in
- error_result: NO, E1, E2, E3, RP

Author: Claude
Date: 2025-11-01
"""
import logging
from typing import List, Dict, Tuple
from app.models.game_models import RunnerMovement, AdvancementResult
from app.core.runner_advancement import GroundballResultType

logger = logging.getLogger(f'{__name__}')

# ============================================================================
# GROUNDBALL ADVANCEMENT TABLES
# ============================================================================
# Structure: {on_base_code: {(defender_in, error_result): GroundballResultType}}
#
# These tables cross-reference:
#   - on_base_code (0-7)
#   - defender_in (True/False)
#   - error_result ('NO', 'E1', 'E2', 'E3', 'RP')
#
# Result is a GroundballResultType which feeds into existing groundball_X() functions

# TODO: Fill these tables with actual data from rulebook
# For now, placeholders with basic logic

G1_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = {
    # on_base_code 0 (bases empty)
    0: {
        (False, 'NO'): GroundballResultType.GROUNDOUT_ROUTINE,
        (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE,
        (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO,
        (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE,
        (False, 'RP'): GroundballResultType.RARE_PLAY,
        (True, 'NO'): GroundballResultType.GROUNDOUT_ROUTINE,
        (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE,
        (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO,
        (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE,
        (True, 'RP'): GroundballResultType.RARE_PLAY,
    },
    # on_base_code 1 (R1 only)
    1: {
        (False, 'NO'): GroundballResultType.GROUNDOUT_DP_ATTEMPT,
        (False, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE,
        (False, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO,
        (False, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE,
        (False, 'RP'): GroundballResultType.RARE_PLAY,
        (True, 'NO'): GroundballResultType.FORCE_AT_THIRD,  # Infield in
        (True, 'E1'): GroundballResultType.SAFE_ALL_ADVANCE_ONE,
        (True, 'E2'): GroundballResultType.SAFE_ALL_ADVANCE_TWO,
        (True, 'E3'): GroundballResultType.SAFE_ALL_ADVANCE_THREE,
        (True, 'RP'): GroundballResultType.RARE_PLAY,
    },
    # TODO: Add codes 2-7
}

G2_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = {
    # Similar structure to G1
    # TODO: Fill with actual data
}

G3_ADVANCEMENT_TABLE: Dict[int, Dict[Tuple[bool, str], GroundballResultType]] = {
    # Similar structure to G1
    # TODO: Fill with actual data
}


def get_groundball_advancement(
    result_type: str,  # 'G1', 'G2', or 'G3'
    on_base_code: int,
    defender_in: bool,
    error_result: str
) -> GroundballResultType:
    """
    Get GroundballResultType for X-Check groundball.

    Args:
        result_type: 'G1', 'G2', or 'G3'
        on_base_code: Current base situation (0-7)
        defender_in: Is defender playing in?
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

    Returns:
        GroundballResultType to pass to existing groundball functions

    Raises:
        ValueError: If parameters invalid
    """
    # Select table
    tables = {
        'G1': G1_ADVANCEMENT_TABLE,
        'G2': G2_ADVANCEMENT_TABLE,
        'G3': G3_ADVANCEMENT_TABLE,
    }

    if result_type not in tables:
        raise ValueError(f"Unknown groundball type: {result_type}")

    table = tables[result_type]

    # Lookup
    key = (defender_in, error_result)

    if on_base_code not in table:
        raise ValueError(f"on_base_code {on_base_code} not in {result_type} table")

    if key not in table[on_base_code]:
        raise ValueError(
            f"Key {key} not in {result_type} table for on_base_code {on_base_code}"
        )

    return table[on_base_code][key]


# ============================================================================
# FLYBALL ADVANCEMENT TABLES
# ============================================================================
# Flyballs are simpler - only cross-reference on_base_code and error_result
# (No defender_in parameter)

# Structure: {on_base_code: {error_result: List[RunnerMovement]}}

F1_ADVANCEMENT_TABLE: Dict[int, Dict[str, List[RunnerMovement]]] = {
    # on_base_code 0 (bases empty)
    0: {
        'NO': [],  # Out, no runners
        'E1': [RunnerMovement(from_base=0, to_base=1, is_out=False)],  # Batter to 1B
        'E2': [RunnerMovement(from_base=0, to_base=2, is_out=False)],  # Batter to 2B
        'E3': [RunnerMovement(from_base=0, to_base=3, is_out=False)],  # Batter to 3B
        'RP': [],  # Rare play - TODO: specific advancement
    },
    # on_base_code 1 (R1 only)
    1: {
        'NO': [
            # F1 = deep fly, R1 advances
            RunnerMovement(from_base=1, to_base=2, is_out=False)
        ],
        'E1': [
            RunnerMovement(from_base=1, to_base=2, is_out=False),
            RunnerMovement(from_base=0, to_base=1, is_out=False),
        ],
        'E2': [
            RunnerMovement(from_base=1, to_base=3, is_out=False),
            RunnerMovement(from_base=0, to_base=2, is_out=False),
        ],
        'E3': [
            RunnerMovement(from_base=1, to_base=4, is_out=False),  # R1 scores
            RunnerMovement(from_base=0, to_base=3, is_out=False),
        ],
        'RP': [],  # TODO
    },
    # TODO: Add codes 2-7
}

F2_ADVANCEMENT_TABLE: Dict[int, Dict[str, List[RunnerMovement]]] = {
    # Similar structure
    # TODO: Fill with actual data
}

F3_ADVANCEMENT_TABLE: Dict[int, Dict[str, List[RunnerMovement]]] = {
    # Similar structure
    # TODO: Fill with actual data
}


def get_flyball_advancement(
    result_type: str,  # 'F1', 'F2', or 'F3'
    on_base_code: int,
    error_result: str
) -> List[RunnerMovement]:
    """
    Get runner movements for X-Check flyball.

    Args:
        result_type: 'F1', 'F2', or 'F3'
        on_base_code: Current base situation (0-7)
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

    Returns:
        List of RunnerMovements

    Raises:
        ValueError: If parameters invalid
    """
    # Select table
    tables = {
        'F1': F1_ADVANCEMENT_TABLE,
        'F2': F2_ADVANCEMENT_TABLE,
        'F3': F3_ADVANCEMENT_TABLE,
    }

    if result_type not in tables:
        raise ValueError(f"Unknown flyball type: {result_type}")

    table = tables[result_type]

    # Lookup
    if on_base_code not in table:
        raise ValueError(f"on_base_code {on_base_code} not in {result_type} table")

    if error_result not in table[on_base_code]:
        raise ValueError(
            f"error_result {error_result} not in {result_type} table for on_base_code {on_base_code}"
        )

    return table[on_base_code][error_result]


# ============================================================================
# HIT ADVANCEMENT (SI1, SI2, DO2, DO3, TR3)
# ============================================================================
# Hits with errors: base advancement + error bonus

def get_hit_advancement(
    result_type: str,  # 'SI1', 'SI2', 'DO2', 'DO3', 'TR3'
    on_base_code: int,
    error_result: str
) -> List[RunnerMovement]:
    """
    Get runner movements for X-Check hit + error.

    For hits, we combine:
    - Base hit advancement (use existing single/double advancement)
    - Error bonus (all runners advance N additional bases)

    Args:
        result_type: Hit type
        on_base_code: Current base situation
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

    Returns:
        List of RunnerMovements

    TODO: Implement proper hit advancement with error bonuses
    For now, placeholder
    """
    movements = []

    # Base advancement for hit type
    base_advances = {
        'SI1': 1,
        'SI2': 1,
        'DO2': 2,
        'DO3': 2,
        'TR3': 3,
    }

    batter_advances = base_advances.get(result_type, 1)

    # Error bonus
    error_bonus = {
        'NO': 0,
        'E1': 1,
        'E2': 2,
        'E3': 3,
        'RP': 0,  # Rare play handled separately
    }

    bonus = error_bonus.get(error_result, 0)

    # Batter advancement
    batter_final = min(batter_advances + bonus, 4)
    movements.append(RunnerMovement(from_base=0, to_base=batter_final, is_out=False))

    # TODO: Advance existing runners based on hit type + error
    # This requires knowing current runner positions

    return movements


# ============================================================================
# OUT ADVANCEMENT (FO, PO)
# ============================================================================

def get_out_advancement(
    result_type: str,  # 'FO' or 'PO'
    on_base_code: int,
    error_result: str
) -> List[RunnerMovement]:
    """
    Get runner movements for X-Check out (foul out or popout).

    If error: all runners advance N bases (error overrides out)
    If no error: batter out, runners hold (or tag if deep enough)

    Args:
        result_type: 'FO' or 'PO'
        on_base_code: Current base situation
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

    Returns:
        List of RunnerMovements
    """
    if error_result == 'NO':
        # Simple out, no advancement
        return []

    # Error on out - all runners advance
    error_advances = {
        'E1': 1,
        'E2': 2,
        'E3': 3,
        'RP': 0,  # Rare play - TODO
    }

    advances = error_advances.get(error_result, 0)

    movements = [
        RunnerMovement(from_base=0, to_base=advances, is_out=False)
    ]

    # TODO: Advance existing runners
    # Need to know which bases are occupied

    return movements

2. Update Runner Advancement Functions

File: backend/app/core/runner_advancement.py

Replace placeholder functions with full implementations:

from app.core.x_check_advancement_tables import (
    get_groundball_advancement,
    get_flyball_advancement,
    get_hit_advancement,
    get_out_advancement,
)

# ============================================================================
# X-CHECK RUNNER ADVANCEMENT
# ============================================================================

def x_check_g1(
    on_base_code: int,
    defender_in: bool,
    error_result: str
) -> AdvancementResult:
    """
    Runner advancement for X-Check G1 result.

    Uses G1 advancement table to get GroundballResultType,
    then calls appropriate groundball_X() function.

    Args:
        on_base_code: Current base situation code
        defender_in: Is the defender playing in?
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

    Returns:
        AdvancementResult with runner movements
    """
    gb_type = get_groundball_advancement('G1', on_base_code, defender_in, error_result)

    # Map GroundballResultType to existing function
    # These functions already exist: groundball_1 through groundball_13
    gb_func_map = {
        GroundballResultType.GROUNDOUT_ROUTINE: groundball_1,
        GroundballResultType.GROUNDOUT_DP_ATTEMPT: groundball_2,
        GroundballResultType.FORCE_AT_THIRD: groundball_3,
        # ... add full mapping based on existing GroundballResultType enum
    }

    if gb_type in gb_func_map:
        return gb_func_map[gb_type](on_base_code)

    # Fallback
    logger.warning(f"Unknown GroundballResultType: {gb_type}, using groundball_1")
    return groundball_1(on_base_code)


def x_check_g2(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check G2 result."""
    gb_type = get_groundball_advancement('G2', on_base_code, defender_in, error_result)
    # Similar logic to x_check_g1
    # TODO: Implement full mapping
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_g3(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check G3 result."""
    gb_type = get_groundball_advancement('G3', on_base_code, defender_in, error_result)
    # Similar logic to x_check_g1
    # TODO: Implement full mapping
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_f1(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check F1 result."""
    movements = get_flyball_advancement('F1', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_f2(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check F2 result."""
    movements = get_flyball_advancement('F2', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_f3(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check F3 result."""
    movements = get_flyball_advancement('F3', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_si1(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check SI1 + error."""
    movements = get_hit_advancement('SI1', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_si2(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check SI2 + error."""
    movements = get_hit_advancement('SI2', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_do2(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check DO2 + error."""
    movements = get_hit_advancement('DO2', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_do3(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check DO3 + error."""
    movements = get_hit_advancement('DO3', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_tr3(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check TR3 + error."""
    movements = get_hit_advancement('TR3', on_base_code, error_result)
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_fo(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check FO (foul out)."""
    movements = get_out_advancement('FO', on_base_code, error_result)
    outs = 0 if error_result != 'NO' else 1
    return AdvancementResult(movements=movements, requires_decision=False)


def x_check_po(on_base_code: int, error_result: str) -> AdvancementResult:
    """Runner advancement for X-Check PO (popout)."""
    movements = get_out_advancement('PO', on_base_code, error_result)
    outs = 0 if error_result != 'NO' else 1
    return AdvancementResult(movements=movements, requires_decision=False)

3. Update PlayResolver to Call Correct Functions

File: backend/app/core/play_resolver.py

Update _get_x_check_advancement to handle all result types:

def _get_x_check_advancement(
    self,
    converted_result: str,
    error_result: str,
    on_base_code: int,
    defender_in: bool
) -> AdvancementResult:
    """
    Get runner advancement for X-Check result.

    Calls appropriate advancement function based on result type.

    Args:
        converted_result: Base result after conversions (G1, F2, SI2, etc.)
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'
        on_base_code: Current base situation
        defender_in: Was defender playing in?

    Returns:
        AdvancementResult
    """
    from app.core.runner_advancement import (
        x_check_g1, x_check_g2, x_check_g3,
        x_check_f1, x_check_f2, x_check_f3,
        x_check_si1, x_check_si2,
        x_check_do2, x_check_do3, x_check_tr3,
        x_check_fo, x_check_po,
    )

    # Map result to function
    advancement_funcs = {
        # Groundballs (need defender_in)
        'G1': lambda: x_check_g1(on_base_code, defender_in, error_result),
        'G2': lambda: x_check_g2(on_base_code, defender_in, error_result),
        'G3': lambda: x_check_g3(on_base_code, defender_in, error_result),
        # Flyballs (no defender_in)
        'F1': lambda: x_check_f1(on_base_code, error_result),
        'F2': lambda: x_check_f2(on_base_code, error_result),
        'F3': lambda: x_check_f3(on_base_code, error_result),
        # Hits
        'SI1': lambda: x_check_si1(on_base_code, error_result),
        'SI2': lambda: x_check_si2(on_base_code, error_result),
        'DO2': lambda: x_check_do2(on_base_code, error_result),
        'DO3': lambda: x_check_do3(on_base_code, error_result),
        'TR3': lambda: x_check_tr3(on_base_code, error_result),
        # Outs
        'FO': lambda: x_check_fo(on_base_code, error_result),
        'PO': lambda: x_check_po(on_base_code, error_result),
    }

    if converted_result in advancement_funcs:
        return advancement_funcs[converted_result]()

    # Fallback
    logger.warning(f"Unknown X-Check result: {converted_result}, no advancement")
    return AdvancementResult(movements=[], requires_decision=False)

Testing Requirements

  1. Unit Tests: tests/core/test_x_check_advancement_tables.py

    • Test get_groundball_advancement() for all combinations
    • Test get_flyball_advancement() for all combinations
    • Test get_hit_advancement() with errors
    • Test get_out_advancement() with errors
  2. Integration Tests: tests/integration/test_x_check_advancement.py

    • Test complete advancement for each result type
    • Test error bonuses applied correctly
    • Test defender_in affects groundball results

Acceptance Criteria

  • x_check_advancement_tables.py created
  • All groundball tables complete (G1, G2, G3)
  • All flyball tables complete (F1, F2, F3)
  • Hit advancement with errors working
  • Out advancement with errors working
  • All x_check_* functions implemented in runner_advancement.py
  • PlayResolver._get_x_check_advancement() updated
  • All unit tests pass
  • All integration tests pass

Notes

  • This phase requires rulebook data for all advancement tables
  • Tables marked TODO need actual values filled in
  • GroundballResultType enum may need new values for X-Check specific results
  • Error bonuses on hits need careful testing (batter advances + runners advance)
  • Rare Play (RP) advancement needs special handling per result type

Next Phase

After completion, proceed to Phase 3E: WebSocket Events & UI Integration