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>
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
-
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
-
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