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>
654 lines
18 KiB
Markdown
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**
|