strat-gameplay-webapp/.claude/implementation/phase-3b-league-config-tables.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

17 KiB
Raw Blame History

Phase 3B: League Config Tables for X-Check Resolution

Status: Not Started Estimated Effort: 3-4 hours Dependencies: Phase 3A (Data Models)

Overview

Create defense tables, error charts, and placeholder advancement tables for X-Check resolution. These tables are used to convert dice rolls into play outcomes.

Tables are stored in league configs with shared common tables imported by both SBA and PD configs.

Tasks

1. Create Common X-Check Tables Module

File: backend/app/config/common_x_check_tables.py (NEW FILE)

"""
Common X-Check resolution tables shared across SBA and PD leagues.

Tables include:
- Defense range tables (20x5) for each position type
- Error charts mapping 3d6 rolls to error types
- Holding runner responsibility chart

Author: Claude
Date: 2025-11-01
"""
from typing import List, Tuple

# ============================================================================
# DEFENSE RANGE TABLES (1d20 × Defense Range 1-5)
# ============================================================================
# Row index = d20 roll - 1 (0-indexed)
# Column index = defense range - 1 (0-indexed)
# Values = base result code (G1, SI2, F2, etc.)

INFIELD_DEFENSE_TABLE: List[List[str]] = [
    # Range:   1      2      3      4      5
    #         Best  Good   Avg    Poor  Worst
    ['G3#',  'SI1', 'SI2', 'SI2', 'SI2'],  # d20 = 1
    ['G2#',  'SI1', 'SI2', 'SI2', 'SI2'],  # d20 = 2
    ['G2#',  'G3#', 'SI1', 'SI2', 'SI2'],  # d20 = 3
    ['G2#',  'G3#', 'SI1', 'SI2', 'SI2'],  # d20 = 4
    ['G1',   'G3#', 'G3#', 'SI1', 'SI2'],  # d20 = 5
    ['G1',   'G2#', 'G3#', 'SI1', 'SI2'],  # d20 = 6
    ['G1',   'G2',  'G3#', 'G3#', 'SI1'],  # d20 = 7
    ['G1',   'G2',  'G3#', 'G3#', 'SI1'],  # d20 = 8
    ['G1',   'G2',  'G3',  'G3#', 'G3#'],  # d20 = 9
    ['G1',   'G1',  'G2',  'G3#', 'G3#'],  # d20 = 10
    ['G1',   'G1',  'G2',  'G3',  'G3#'],  # d20 = 11
    ['G1',   'G1',  'G2',  'G3',  'G3#'],  # d20 = 12
    ['G1',   'G1',  'G2',  'G3',  'G3'],   # d20 = 13
    ['G1',   'G1',  'G2',  'G2',  'G3'],   # d20 = 14
    ['G1',   'G1',  'G1',  'G2',  'G3'],   # d20 = 15
    ['G1',   'G1',  'G1',  'G2',  'G3'],   # d20 = 16
    ['G1',   'G1',  'G1',  'G1',  'G3'],   # d20 = 17
    ['G1',   'G1',  'G1',  'G1',  'G2'],   # d20 = 18
    ['G1',   'G1',  'G1',  'G1',  'G2'],   # d20 = 19
    ['G1',   'G1',  'G1',  'G1',  'G1'],   # d20 = 20
]

OUTFIELD_DEFENSE_TABLE: List[List[str]] = [
    # Range:   1      2      3      4      5
    #         Best  Good   Avg    Poor  Worst
    ['TR3',  'DO3', 'DO3', 'DO3', 'DO3'],  # d20 = 1
    ['DO3',  'DO3', 'DO3', 'DO3', 'DO3'],  # d20 = 2
    ['DO2',  'DO3', 'DO3', 'DO3', 'DO3'],  # d20 = 3
    ['DO2',  'DO2', 'DO3', 'DO3', 'DO3'],  # d20 = 4
    ['SI2',  'DO2', 'DO2', 'DO3', 'DO3'],  # d20 = 5
    ['SI2',  'SI2', 'DO2', 'DO2', 'DO3'],  # d20 = 6
    ['F1',   'SI2', 'SI2', 'DO2', 'DO2'],  # d20 = 7
    ['F1',   'F1',  'SI2', 'SI2', 'DO2'],  # d20 = 8
    ['F1',   'F1',  'F1',  'SI2', 'SI2'],  # d20 = 9
    ['F1',   'F1',  'F1',  'SI2', 'SI2'],  # d20 = 10
    ['F1',   'F1',  'F1',  'F1',  'SI2'],  # d20 = 11
    ['F1',   'F1',  'F1',  'F1',  'SI2'],  # d20 = 12
    ['F1',   'F1',  'F1',  'F1',  'F1'],   # d20 = 13
    ['F2',   'F1',  'F1',  'F1',  'F1'],   # d20 = 14
    ['F2',   'F2',  'F1',  'F1',  'F1'],   # d20 = 15
    ['F2',   'F2',  'F2',  'F1',  'F1'],   # d20 = 16
    ['F2',   'F2',  'F2',  'F2',  'F1'],   # d20 = 17
    ['F3',   'F2',  'F2',  'F2',  'F2'],   # d20 = 18
    ['F3',   'F3',  'F2',  'F2',  'F2'],   # d20 = 19
    ['F3',   'F3',  'F3',  'F2',  'F2'],   # d20 = 20
]

CATCHER_DEFENSE_TABLE: List[List[str]] = [
    # Range:   1      2      3      4      5
    #         Best  Good   Avg    Poor  Worst
    ['G3',   'SI1', 'SI1', 'SI1', 'SI1'],  # d20 = 1
    ['G2',   'G3',  'SI1', 'SI1', 'SI1'],  # d20 = 2
    ['G2',   'G3',  'SI1', 'SI1', 'SI1'],  # d20 = 3
    ['G1',   'G2',  'G3',  'SI1', 'SI1'],  # d20 = 4
    ['G1',   'G2',  'G3',  'SI1', 'SI1'],  # d20 = 5
    ['G1',   'G1',  'G2',  'G3',  'SI1'],  # d20 = 6
    ['G1',   'G1',  'G2',  'G3',  'SI1'],  # d20 = 7
    ['G1',   'G1',  'G1',  'G2',  'G3'],   # d20 = 8
    ['G1',   'G1',  'G1',  'G2',  'G3'],   # d20 = 9
    ['SPD',  'G1',  'G1',  'G1',  'G2'],   # d20 = 10
    ['SPD',  'SPD', 'G1',  'G1',  'G1'],   # d20 = 11
    ['SPD',  'SPD', 'SPD', 'G1',  'G1'],   # d20 = 12
    ['FO',   'SPD', 'SPD', 'SPD', 'G1'],   # d20 = 13
    ['FO',   'FO',  'SPD', 'SPD', 'SPD'],  # d20 = 14
    ['FO',   'FO',  'FO',  'SPD', 'SPD'],  # d20 = 15
    ['PO',   'FO',  'FO',  'FO',  'SPD'],  # d20 = 16
    ['PO',   'PO',  'FO',  'FO',  'FO'],   # d20 = 17
    ['PO',   'PO',  'PO',  'FO',  'FO'],   # d20 = 18
    ['PO',   'PO',  'PO',  'PO',  'FO'],   # d20 = 19
    ['PO',   'PO',  'PO',  'PO',  'PO'],   # d20 = 20
]

# ============================================================================
# ERROR CHARTS (3d6 totals by Error Rating and Position Type)
# ============================================================================
# Structure: {error_rating: {'RP': [rolls], 'E1': [rolls], 'E2': [rolls], 'E3': [rolls]}}
# If 3d6 sum is in the list for that error rating, apply that error type
# Otherwise, error_result = 'NO' (no error)

# Corner Outfield (LF, RF) Error Chart
LF_RF_ERROR_CHART: dict[int, dict[str, List[int]]] = {
    0:  {'RP': [5],       'E1': [],             'E2': [],                'E3': []},
    1:  {'RP': [5],       'E1': [3],            'E2': [],                'E3': [17]},
    2:  {'RP': [5],       'E1': [3, 18],        'E2': [],                'E3': [16]},
    3:  {'RP': [5],       'E1': [3, 18],        'E2': [],                'E3': [15]},
    4:  {'RP': [5],       'E1': [4],            'E2': [3, 15],           'E3': [18]},
    5:  {'RP': [5],       'E1': [4],            'E2': [14],              'E3': [18]},
    6:  {'RP': [5],       'E1': [3, 4],         'E2': [14, 17],          'E3': [18]},
    7:  {'RP': [5],       'E1': [16],           'E2': [6, 15],           'E3': [18]},
    8:  {'RP': [5],       'E1': [16],           'E2': [6, 15, 17],       'E3': [3, 18]},
    9:  {'RP': [5],       'E1': [16],           'E2': [11],              'E3': [3, 18]},
    10: {'RP': [5],       'E1': [4, 16],        'E2': [14, 15, 17],      'E3': [3, 18]},
    11: {'RP': [5],       'E1': [4, 16],        'E2': [13, 15],          'E3': [3, 18]},
    12: {'RP': [5],       'E1': [4, 16],        'E2': [13, 14],          'E3': [3, 18]},
    13: {'RP': [5],       'E1': [6],            'E2': [4, 12, 15],       'E3': [17]},
    14: {'RP': [5],       'E1': [3, 6],         'E2': [12, 14],          'E3': [17]},
    15: {'RP': [5],       'E1': [3, 6, 18],     'E2': [4, 12, 14],       'E3': [17]},
    16: {'RP': [5],       'E1': [4, 6],         'E2': [12, 13],          'E3': [17]},
    17: {'RP': [5],       'E1': [4, 6],         'E2': [9, 12],           'E3': [17]},
    18: {'RP': [5],       'E1': [3, 4, 6],      'E2': [11, 12, 18],      'E3': [17]},
    19: {'RP': [5],       'E1': [7],            'E2': [3, 10, 11],       'E3': [17, 18]},
    20: {'RP': [5],       'E1': [3, 7],         'E2': [11, 13, 16],      'E3': [17, 18]},
    21: {'RP': [5],       'E1': [3, 7],         'E2': [11, 12, 15],      'E3': [17, 18]},
    22: {'RP': [5],       'E1': [3, 6, 16],     'E2': [9, 12, 14],       'E3': [17, 18]},
    23: {'RP': [5],       'E1': [4, 7],         'E2': [11, 12, 14],      'E3': [17, 18]},
    24: {'RP': [5],       'E1': [4, 6, 16],     'E2': [8, 11, 13],       'E3': [3, 17, 18]},
    25: {'RP': [5],       'E1': [4, 6, 16],     'E2': [11, 12, 13],      'E3': [3, 17, 18]},
}

# Center Field Error Chart
CF_ERROR_CHART: dict[int, dict[str, List[int]]] = {
    0:  {'RP': [5],       'E1': [],             'E2': [],                'E3': []},
    1:  {'RP': [5],       'E1': [3],            'E2': [],                'E3': [17]},
    2:  {'RP': [5],       'E1': [3, 18],        'E2': [],                'E3': [16]},
    3:  {'RP': [5],       'E1': [3, 18],        'E2': [],                'E3': [15]},
    4:  {'RP': [5],       'E1': [4],            'E2': [3, 15],           'E3': [18]},
    5:  {'RP': [5],       'E1': [4],            'E2': [14],              'E3': [18]},
    6:  {'RP': [5],       'E1': [3, 4],         'E2': [14, 17],          'E3': [18]},
    7:  {'RP': [5],       'E1': [16],           'E2': [6, 15],           'E3': [18]},
    8:  {'RP': [5],       'E1': [16],           'E2': [6, 15, 17],       'E3': [3, 18]},
    9:  {'RP': [5],       'E1': [16],           'E2': [11],              'E3': [3, 18]},
    10: {'RP': [5],       'E1': [4, 16],        'E2': [14, 15, 17],      'E3': [3, 18]},
    11: {'RP': [5],       'E1': [4, 16],        'E2': [13, 15],          'E3': [3, 18]},
    12: {'RP': [5],       'E1': [4, 16],        'E2': [13, 14],          'E3': [3, 18]},
    13: {'RP': [5],       'E1': [6],            'E2': [4, 12, 15],       'E3': [17]},
    14: {'RP': [5],       'E1': [3, 6],         'E2': [12, 14],          'E3': [17]},
    15: {'RP': [5],       'E1': [3, 6, 18],     'E2': [4, 12, 14],       'E3': [17]},
    16: {'RP': [5],       'E1': [4, 6],         'E2': [12, 13],          'E3': [17]},
    17: {'RP': [5],       'E1': [4, 6],         'E2': [9, 12],           'E3': [17]},
    18: {'RP': [5],       'E1': [3, 4, 6],      'E2': [11, 12, 18],      'E3': [17]},
    19: {'RP': [5],       'E1': [7],            'E2': [3, 10, 11],       'E3': [17, 18]},
    20: {'RP': [5],       'E1': [3, 7],         'E2': [11, 13, 16],      'E3': [17, 18]},
    21: {'RP': [5],       'E1': [3, 7],         'E2': [11, 12, 15],      'E3': [17, 18]},
    22: {'RP': [5],       'E1': [3, 6, 16],     'E2': [9, 12, 14],       'E3': [17, 18]},
    23: {'RP': [5],       'E1': [4, 7],         'E2': [11, 12, 14],      'E3': [17, 18]},
    24: {'RP': [5],       'E1': [4, 6, 16],     'E2': [8, 11, 13],       'E3': [3, 17, 18]},
    25: {'RP': [5],       'E1': [4, 6, 16],     'E2': [11, 12, 13],      'E3': [3, 17, 18]},
}

# Infield Error Charts
# TODO: Add P, C, 1B, 2B, 3B, SS error charts
# Structure same as OF charts above
# Placeholder for now - to be filled with actual data

PITCHER_ERROR_CHART: dict[int, dict[str, List[int]]] = {}  # TODO
CATCHER_ERROR_CHART: dict[int, dict[str, List[int]]] = {}  # TODO
FIRST_BASE_ERROR_CHART: dict[int, dict[str, List[int]]] = {}  # TODO
SECOND_BASE_ERROR_CHART: dict[int, dict[str, List[int]]] = {}  # TODO
THIRD_BASE_ERROR_CHART: dict[int, dict[str, List[int]]] = {}  # TODO
SHORTSTOP_ERROR_CHART: dict[int, dict[str, List[int]]] = {}  # TODO

# ============================================================================
# HOLDING RUNNER RESPONSIBILITY CHART
# ============================================================================

def get_fielders_holding_runners(
    runner_bases: List[int],
    batter_handedness: str
) -> List[str]:
    """
    Determine which fielders are responsible for holding runners.

    Used to determine if G2#/G3# results should convert to SI2.

    Args:
        runner_bases: List of bases with runners (e.g., [1, 3] for R1 and R3)
        batter_handedness: 'L' or 'R'

    Returns:
        List of position codes responsible for holds (e.g., ['1B', 'SS'])

    TODO: Implement full chart logic when chart is provided
    For now, simple heuristic:
        - R1 only: 1B holds
        - R1 + others: 2B or SS holds depending on handedness
        - R2 only: No holds
        - R3 only: No holds
    """
    if not runner_bases:
        return []

    holding_positions = []

    if 1 in runner_bases:
        # Runner on first
        if len(runner_bases) == 1:
            # Only R1
            holding_positions.append('1B')
        else:
            # R1 + others - middle infielder holds
            if batter_handedness == 'R':
                holding_positions.append('SS')
            else:
                holding_positions.append('2B')

    return holding_positions


# ============================================================================
# ERROR CHART LOOKUP HELPER
# ============================================================================

def get_error_chart_for_position(position: str) -> dict[int, dict[str, List[int]]]:
    """
    Get error chart for a specific position.

    Args:
        position: Position code (P, C, 1B, 2B, 3B, SS, LF, CF, RF)

    Returns:
        Error chart dict

    Raises:
        ValueError: If position not recognized
    """
    charts = {
        'P': PITCHER_ERROR_CHART,
        'C': CATCHER_ERROR_CHART,
        '1B': FIRST_BASE_ERROR_CHART,
        '2B': SECOND_BASE_ERROR_CHART,
        '3B': THIRD_BASE_ERROR_CHART,
        'SS': SHORTSTOP_ERROR_CHART,
        'LF': LF_RF_ERROR_CHART,
        'RF': LF_RF_ERROR_CHART,
        'CF': CF_ERROR_CHART,
    }

    if position not in charts:
        raise ValueError(f"Unknown position: {position}")

    return charts[position]

2. Import Common Tables in League Configs

File: backend/app/config/sba_config.py

Add imports:

from app.config.common_x_check_tables import (
    INFIELD_DEFENSE_TABLE,
    OUTFIELD_DEFENSE_TABLE,
    CATCHER_DEFENSE_TABLE,
    LF_RF_ERROR_CHART,
    CF_ERROR_CHART,
    get_fielders_holding_runners,
    get_error_chart_for_position,
)

# Use common tables (no overrides for SBA)
X_CHECK_DEFENSE_TABLES = {
    'infield': INFIELD_DEFENSE_TABLE,
    'outfield': OUTFIELD_DEFENSE_TABLE,
    'catcher': CATCHER_DEFENSE_TABLE,
}

X_CHECK_ERROR_CHARTS = get_error_chart_for_position  # Use common function

File: backend/app/config/pd_config.py

Add same imports (for now, PD uses common tables):

from app.config.common_x_check_tables import (
    INFIELD_DEFENSE_TABLE,
    OUTFIELD_DEFENSE_TABLE,
    CATCHER_DEFENSE_TABLE,
    LF_RF_ERROR_CHART,
    CF_ERROR_CHART,
    get_fielders_holding_runners,
    get_error_chart_for_position,
)

X_CHECK_DEFENSE_TABLES = {
    'infield': INFIELD_DEFENSE_TABLE,
    'outfield': OUTFIELD_DEFENSE_TABLE,
    'catcher': CATCHER_DEFENSE_TABLE,
}

X_CHECK_ERROR_CHARTS = get_error_chart_for_position

3. Add Placeholder Runner Advancement Functions

File: backend/app/core/runner_advancement.py

Add at end of file (placeholders for Phase 3D):

# ============================================================================
# X-CHECK RUNNER ADVANCEMENT (Placeholders - to be implemented in Phase 3D)
# ============================================================================

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

    TODO: Implement full table lookups in Phase 3D

    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
    """
    # Placeholder
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_g2(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult:
    """X-Check G2 advancement (TODO: Phase 3D)."""
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_g3(on_base_code: int, defender_in: bool, error_result: str) -> AdvancementResult:
    """X-Check G3 advancement (TODO: Phase 3D)."""
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_f1(on_base_code: int, error_result: str) -> AdvancementResult:
    """X-Check F1 advancement (TODO: Phase 3D)."""
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_f2(on_base_code: int, error_result: str) -> AdvancementResult:
    """X-Check F2 advancement (TODO: Phase 3D)."""
    return AdvancementResult(movements=[], requires_decision=False)


def x_check_f3(on_base_code: int, error_result: str) -> AdvancementResult:
    """X-Check F3 advancement (TODO: Phase 3D)."""
    return AdvancementResult(movements=[], requires_decision=False)


# Add more placeholders for SI1, SI2, DO2, DO3, TR3, FO, PO as needed

Testing Requirements

  1. Unit Tests: tests/config/test_x_check_tables.py

    • Test defense table dimensions (20 rows × 5 columns)
    • Test error chart structure
    • Test get_error_chart_for_position()
    • Test get_fielders_holding_runners() with various scenarios
  2. Unit Tests: tests/core/test_runner_advancement.py

    • Test placeholder functions return valid AdvancementResult
    • Verify function signatures

Acceptance Criteria

  • common_x_check_tables.py created with all defense tables
  • LF/RF and CF error charts complete
  • Placeholder error charts for P, C, 1B, 2B, 3B, SS (to be filled)
  • get_fielders_holding_runners() stubbed with basic logic
  • get_error_chart_for_position() implemented
  • SBA and PD configs import common tables
  • Placeholder advancement functions added to runner_advancement.py
  • All unit tests pass
  • No import errors

Notes

  • Infield error charts (P, C, 1B, 2B, 3B, SS) need actual data - marked as TODO
  • Holding runners chart needs full specification - using heuristic for now
  • Runner advancement functions are placeholders - Phase 3D will implement full logic
  • Both leagues use same tables for now - can override in league configs if needed

Next Phase

After completion, proceed to Phase 3C: X-Check Resolution Logic