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

654 lines
18 KiB
Markdown

# 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:
```python
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):
```python
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**:
```python
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**:
```python
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**