strat-gameplay-webapp/.claude/implementation/phase-3c-resolution-logic.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

18 KiB

Phase 3C: X-Check Resolution Logic in PlayResolver

Status: Not Started Estimated Effort: 4-5 hours Dependencies: Phase 3A (Data Models), Phase 3B (Config Tables)

Overview

Implement the core X-Check resolution logic in PlayResolver. This includes:

  • Dice rolling (1d20 + 3d6)
  • Defense table lookups
  • SPD test resolution
  • G2#/G3# conversion logic
  • Error chart lookups
  • Final outcome determination

Tasks

1. Add X-Check Resolution to PlayResolver

File: backend/app/core/play_resolver.py

Add import at top:

from app.models.game_models import XCheckResult
from app.config.common_x_check_tables import (
    INFIELD_DEFENSE_TABLE,
    OUTFIELD_DEFENSE_TABLE,
    CATCHER_DEFENSE_TABLE,
    get_error_chart_for_position,
    get_fielders_holding_runners,
)

Add to resolve_play method (in the long conditional):

def resolve_play(
    self,
    outcome: PlayOutcome,
    state: GameState,
    batter: BasePlayer,
    pitcher: BasePlayer,
    hit_location: Optional[str] = None,
    # ... other params
) -> PlayResult:
    """Resolve a play outcome into game state changes."""

    # ... existing code ...

    elif outcome == PlayOutcome.X_CHECK:
        # X-Check requires position in hit_location
        if not hit_location:
            raise ValueError("X-Check outcome requires hit_location (position)")

        return self._resolve_x_check(
            position=hit_location,
            state=state,
            batter=batter,
            pitcher=pitcher,
        )

    # ... rest of conditionals ...

Add _resolve_x_check method:

def _resolve_x_check(
    self,
    position: str,
    state: GameState,
    batter: BasePlayer,
    pitcher: BasePlayer,
) -> PlayResult:
    """
    Resolve X-Check play with defense range and error tables.

    Process:
        1. Get defender and their ratings
        2. Roll 1d20 + 3d6
        3. Adjust range if playing in
        4. Look up base result from defense table
        5. Apply SPD test if needed
        6. Apply G2#/G3# conversion if applicable
        7. Look up error result from error chart
        8. Determine final outcome
        9. Get runner advancement
        10. Create Play record

    Args:
        position: Position being checked (SS, LF, 3B, etc.)
        state: Current game state
        batter: Batting player
        pitcher: Pitching player

    Returns:
        PlayResult with x_check_details populated

    Raises:
        ValueError: If defender has no position rating
    """
    logger.info(f"Resolving X-Check to {position}")

    # Step 1: Get defender
    defender = self._get_defender_at_position(state, position)
    if not defender.active_position_rating:
        raise ValueError(
            f"Defender at {position} ({defender.name}) has no position rating loaded"
        )

    # Step 2: Roll dice
    d20_roll = self.dice.roll_d20()
    d6_roll = self.dice.roll_3d6()  # Sum of 3d6

    logger.debug(f"X-Check rolls: d20={d20_roll}, 3d6={d6_roll}")

    # Step 3: Adjust range if playing in
    base_range = defender.active_position_rating.range
    adjusted_range = self._adjust_range_for_defensive_position(
        base_range=base_range,
        position=position,
        state=state
    )

    # Step 4: Look up base result
    base_result = self._lookup_defense_table(
        position=position,
        d20_roll=d20_roll,
        defense_range=adjusted_range
    )

    logger.debug(f"Base result from defense table: {base_result}")

    # Step 5: Apply SPD test if needed
    converted_result = base_result
    spd_test_roll = None
    spd_test_target = None
    spd_test_passed = None

    if base_result == 'SPD':
        converted_result, spd_test_roll, spd_test_target, spd_test_passed = \
            self._resolve_spd_test(batter)
        logger.debug(
            f"SPD test: roll={spd_test_roll}, target={spd_test_target}, "
            f"passed={spd_test_passed}, result={converted_result}"
        )

    # Step 6: Apply G2#/G3# conversion if applicable
    if converted_result in ['G2#', 'G3#']:
        converted_result = self._apply_hash_conversion(
            result=converted_result,
            position=position,
            adjusted_range=adjusted_range,
            base_range=base_range,
            state=state,
            batter=batter
        )

    # Step 7: Look up error result
    error_result = self._lookup_error_chart(
        position=position,
        error_rating=defender.active_position_rating.error,
        d6_roll=d6_roll
    )

    logger.debug(f"Error result: {error_result}")

    # Step 8: Determine final outcome
    final_outcome, hit_type = self._determine_final_x_check_outcome(
        converted_result=converted_result,
        error_result=error_result
    )

    # Step 9: Create XCheckResult
    x_check_details = XCheckResult(
        position=position,
        d20_roll=d20_roll,
        d6_roll=d6_roll,
        defender_range=adjusted_range,
        defender_error_rating=defender.active_position_rating.error,
        defender_id=defender.id,
        base_result=base_result,
        converted_result=converted_result,
        error_result=error_result,
        final_outcome=final_outcome,
        hit_type=hit_type,
        spd_test_roll=spd_test_roll,
        spd_test_target=spd_test_target,
        spd_test_passed=spd_test_passed,
    )

    # Step 10: Get runner advancement
    # Check if defender was playing in for advancement purposes
    defender_in = (adjusted_range > base_range)

    advancement = self._get_x_check_advancement(
        converted_result=converted_result,
        error_result=error_result,
        on_base_code=state.get_on_base_code(),
        defender_in=defender_in
    )

    # Step 11: Create PlayResult
    return PlayResult(
        outcome=final_outcome,
        advancement=advancement,
        x_check_details=x_check_details,
        outs_recorded=1 if final_outcome.is_out() and error_result == 'NO' else 0,
    )

2. Add Helper Methods

Add these methods to PlayResolver class:

def _get_defender_at_position(
    self,
    state: GameState,
    position: str
) -> BasePlayer:
    """
    Get defender currently playing at position.

    Args:
        state: Current game state
        position: Position code (SS, LF, etc.)

    Returns:
        BasePlayer at that position

    Raises:
        ValueError: If no defender at position
    """
    # Get defensive team's lineup
    defensive_lineup = (
        state.away_lineup if state.is_bottom_inning
        else state.home_lineup
    )

    # Find player at position
    for player in defensive_lineup.get_defensive_positions():
        if player.current_position == position:
            return player

    raise ValueError(f"No defender found at position {position}")


def _adjust_range_for_defensive_position(
    self,
    base_range: int,
    position: str,
    state: GameState
) -> int:
    """
    Adjust defense range for defensive positioning.

    If defender is playing in, range increases by 1 (max 5).

    Args:
        base_range: Defender's base range (1-5)
        position: Position code
        state: Current game state

    Returns:
        Adjusted range (1-5)
    """
    # Check if position is playing in based on defensive decision
    decision = state.current_defensive_decision

    playing_in = False

    if decision.corners_in and position in ['1B', '3B', 'P', 'C']:
        playing_in = True
    elif decision.infield_in and position in ['1B', '2B', '3B', 'SS', 'P', 'C']:
        playing_in = True

    if playing_in:
        adjusted = min(base_range + 1, 5)
        logger.debug(f"{position} playing in: range {base_range}{adjusted}")
        return adjusted

    return base_range


def _lookup_defense_table(
    self,
    position: str,
    d20_roll: int,
    defense_range: int
) -> str:
    """
    Look up base result from defense table.

    Args:
        position: Position code (determines which table)
        d20_roll: 1-20 (row selector)
        defense_range: 1-5 (column selector)

    Returns:
        Base result code (G1, F2, SI2, SPD, etc.)
    """
    # Determine which table to use
    if position in ['P', 'C', '1B', '2B', '3B', 'SS']:
        if position == 'C':
            table = CATCHER_DEFENSE_TABLE
        else:
            table = INFIELD_DEFENSE_TABLE
    else:  # LF, CF, RF
        table = OUTFIELD_DEFENSE_TABLE

    # Lookup (0-indexed)
    row = d20_roll - 1
    col = defense_range - 1

    result = table[row][col]
    logger.debug(f"Defense table[{d20_roll}][{defense_range}] = {result}")

    return result


def _resolve_spd_test(
    self,
    batter: BasePlayer
) -> Tuple[str, int, int, bool]:
    """
    Resolve SPD (speed test) result.

    Roll 1d20 and compare to batter's speed rating.
    - If roll <= speed: SI1
    - If roll > speed: G3

    Args:
        batter: Batting player

    Returns:
        Tuple of (result, roll, target, passed)

    Raises:
        ValueError: If batter has no speed rating
    """
    # Get speed rating
    speed = self._get_batter_speed(batter)

    # Roll d20
    roll = self.dice.roll_d20()

    # Compare
    passed = (roll <= speed)
    result = 'SI1' if passed else 'G3'

    logger.info(
        f"SPD test: {batter.name} speed={speed}, roll={roll}, "
        f"{'PASSED' if passed else 'FAILED'}{result}"
    )

    return result, roll, speed, passed


def _get_batter_speed(self, batter: BasePlayer) -> int:
    """
    Get batter's speed rating for SPD test.

    Args:
        batter: Batting player

    Returns:
        Speed value (0-20)

    Raises:
        ValueError: If speed rating not available
    """
    # PD players: speed from batting_card.running
    if hasattr(batter, 'batting_card') and batter.batting_card:
        return batter.batting_card.running

    # SBA players: TODO - need to add speed field or get from manual input
    raise ValueError(f"No speed rating available for {batter.name}")


def _apply_hash_conversion(
    self,
    result: str,
    position: str,
    adjusted_range: int,
    base_range: int,
    state: GameState,
    batter: BasePlayer
) -> str:
    """
    Convert G2# or G3# to SI2 if conditions are met.

    Conversion happens if:
    a) Infielder is playing in (range was adjusted), OR
    b) Infielder is responsible for holding a runner

    Args:
        result: 'G2#' or 'G3#'
        position: Position code
        adjusted_range: Range after playing-in adjustment
        base_range: Original range
        state: Current game state
        batter: Batting player

    Returns:
        'SI2' if converted, otherwise original result without # ('G2' or 'G3')
    """
    # Check condition (a): playing in
    if adjusted_range > base_range:
        logger.debug(f"{result} → SI2 (defender playing in)")
        return 'SI2'

    # Check condition (b): holding runner
    runner_bases = state.get_runner_bases()
    batter_hand = self._get_batter_handedness(batter)

    holding_positions = get_fielders_holding_runners(runner_bases, batter_hand)

    if position in holding_positions:
        logger.debug(f"{result} → SI2 (defender holding runner)")
        return 'SI2'

    # No conversion - remove # suffix
    base_result = result.replace('#', '')
    logger.debug(f"{result}{base_result} (no conversion)")
    return base_result


def _get_batter_handedness(self, batter: BasePlayer) -> str:
    """
    Get batter handedness (L or R).

    Args:
        batter: Batting player

    Returns:
        'L' or 'R'
    """
    # PD players
    if hasattr(batter, 'batting_card') and batter.batting_card:
        return batter.batting_card.hand

    # SBA players - TODO: add handedness field
    return 'R'  # Default to right-handed


def _lookup_error_chart(
    self,
    position: str,
    error_rating: int,
    d6_roll: int
) -> str:
    """
    Look up error result from error chart.

    Args:
        position: Position code
        error_rating: Defender's error rating (0-25)
        d6_roll: Sum of 3d6 (3-18)

    Returns:
        Error result: 'NO', 'E1', 'E2', 'E3', or 'RP'
    """
    error_chart = get_error_chart_for_position(position)

    # Get row for this error rating
    if error_rating not in error_chart:
        logger.warning(f"Error rating {error_rating} not in chart, using 0")
        error_rating = 0

    rating_row = error_chart[error_rating]

    # Check each error type
    for error_type in ['RP', 'E3', 'E2', 'E1']:  # Check in priority order
        if d6_roll in rating_row[error_type]:
            logger.debug(f"Error chart: 3d6={d6_roll}{error_type}")
            return error_type

    # No error
    logger.debug(f"Error chart: 3d6={d6_roll} → NO")
    return 'NO'


def _determine_final_x_check_outcome(
    self,
    converted_result: str,
    error_result: str
) -> Tuple[PlayOutcome, str]:
    """
    Determine final outcome and hit_type from converted result + error.

    Logic:
        - If Out + Error: outcome = ERROR, hit_type = '{result}_plus_error_{n}'
        - If Hit + Error: outcome = hit type, hit_type = '{result}_plus_error_{n}'
        - If No Error: outcome = base outcome, hit_type = '{result}_no_error'
        - If Rare Play: hit_type includes '_rare_play'

    Args:
        converted_result: Result after SPD/# conversions (G1, F2, SI2, etc.)
        error_result: 'NO', 'E1', 'E2', 'E3', 'RP'

    Returns:
        Tuple of (final_outcome, hit_type)
    """
    # Map result codes to PlayOutcome
    result_map = {
        'SI1': PlayOutcome.SINGLE_1,
        'SI2': PlayOutcome.SINGLE_2,
        'DO2': PlayOutcome.DOUBLE_2,
        'DO3': PlayOutcome.DOUBLE_3,
        'TR3': PlayOutcome.TRIPLE,
        'G1': PlayOutcome.GROUNDBALL_B,  # Map to existing groundball
        'G2': PlayOutcome.GROUNDBALL_B,
        'G3': PlayOutcome.GROUNDBALL_C,
        'F1': PlayOutcome.FLYOUT_A,  # Map to existing flyout
        'F2': PlayOutcome.FLYOUT_B,
        'F3': PlayOutcome.FLYOUT_C,
        'FO': PlayOutcome.LINEOUT,  # Foul out
        'PO': PlayOutcome.POPOUT,
    }

    base_outcome = result_map.get(converted_result)
    if not base_outcome:
        raise ValueError(f"Unknown X-Check result: {converted_result}")

    # Build hit_type string
    result_lower = converted_result.lower()

    if error_result == 'NO':
        # No error
        hit_type = f"{result_lower}_no_error"
        final_outcome = base_outcome

    elif error_result == 'RP':
        # Rare play
        hit_type = f"{result_lower}_rare_play"
        # Rare plays are treated like errors for stats
        final_outcome = PlayOutcome.ERROR

    else:
        # E1, E2, E3
        error_num = error_result[1]  # Extract '1', '2', or '3'
        hit_type = f"{result_lower}_plus_error_{error_num}"

        # If base was an out, error overrides to ERROR outcome
        if base_outcome.is_out():
            final_outcome = PlayOutcome.ERROR
        else:
            # Hit + error: keep hit outcome
            final_outcome = base_outcome

    logger.info(f"Final: {converted_result} + {error_result}{final_outcome.value} ({hit_type})")

    return final_outcome, hit_type


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

    Note: Uses placeholder functions from Phase 3B.
          Full implementation in Phase 3D.
    """
    from app.core.runner_advancement import (
        x_check_g1, x_check_g2, x_check_g3,
        x_check_f1, x_check_f2, x_check_f3,
    )

    # Map to advancement function
    advancement_funcs = {
        'G1': x_check_g1,
        'G2': x_check_g2,
        'G3': x_check_g3,
        'F1': x_check_f1,
        'F2': x_check_f2,
        'F3': x_check_f3,
    }

    if converted_result in advancement_funcs:
        # Groundball or flyball - needs special tables
        func = advancement_funcs[converted_result]
        if converted_result.startswith('G'):
            return func(on_base_code, defender_in, error_result)
        else:  # Flyball
            return func(on_base_code, error_result)

    # For hits (SI1, SI2, DO2, DO3, TR3), use standard advancement
    # with error adding extra bases
    # TODO: May need custom advancement for hits + errors
    return AdvancementResult(movements=[], requires_decision=False)

Testing Requirements

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

    • Test _lookup_defense_table() for all position types
    • Test _resolve_spd_test() with various speeds
    • Test _apply_hash_conversion() with all conditions
    • Test _lookup_error_chart() for known values
    • Test _determine_final_x_check_outcome() for all error types
    • Test _adjust_range_for_defensive_position()
  2. Integration Tests: tests/integration/test_x_check_flow.py

    • Test complete X-Check resolution (infield)
    • Test complete X-Check resolution (outfield)
    • Test complete X-Check resolution (catcher with SPD)
    • Test G2# conversion scenarios
    • Test error overriding outs

Acceptance Criteria

  • _resolve_x_check() method implemented
  • All helper methods implemented
  • Defense table lookup working for all positions
  • SPD test resolution working
  • G2#/G3# conversion logic working
  • Error chart lookup working
  • Final outcome determination working
  • Integration with PlayResolver.resolve_play()
  • All unit tests pass
  • All integration tests pass
  • Logging at debug/info levels throughout

Notes

  • SBA players need speed rating - may require manual input or model update
  • Advancement functions are placeholders - will be filled in Phase 3D
  • Error priority order: RP > E3 > E2 > E1 > NO
  • Playing in increases range by 1 (max 5) AND triggers # conversion
  • Holding runner triggers # conversion but doesn't change range

Next Phase

After completion, proceed to Phase 3D: Runner Advancement Tables