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>
320 lines
9.4 KiB
Markdown
320 lines
9.4 KiB
Markdown
# Phase 3A: Data Models & Enums for X-Check System
|
|
|
|
**Status**: Not Started
|
|
**Estimated Effort**: 2-3 hours
|
|
**Dependencies**: None
|
|
|
|
## Overview
|
|
|
|
Add data models and enums to support X-Check play resolution. This includes:
|
|
- PositionRating model for defensive ratings
|
|
- XCheckResult intermediate state object
|
|
- PlayOutcome.X_CHECK enum value
|
|
- Updates to Play model documentation
|
|
|
|
## Tasks
|
|
|
|
### 1. Add PositionRating Model to player_models.py
|
|
|
|
**File**: `backend/app/models/player_models.py`
|
|
|
|
**Location**: After PdPitchingCard class (around line 289)
|
|
|
|
```python
|
|
class PositionRating(BaseModel):
|
|
"""
|
|
Defensive rating for a player at a specific position.
|
|
|
|
Used for X-Check play resolution. Ratings come from:
|
|
- PD: API endpoint /api/v2/cardpositions/player/:player_id
|
|
- SBA: Read from physical cards by players
|
|
"""
|
|
position: str = Field(..., description="Position code (SS, LF, CF, etc.)")
|
|
innings: int = Field(..., description="Innings played at position")
|
|
range: int = Field(..., ge=1, le=5, description="Defense range (1=best, 5=worst)")
|
|
error: int = Field(..., ge=0, le=25, description="Error rating (0=best, 25=worst)")
|
|
arm: Optional[int] = Field(None, description="Throwing arm rating")
|
|
pb: Optional[int] = Field(None, description="Passed balls (catchers only)")
|
|
overthrow: Optional[int] = Field(None, description="Overthrow risk")
|
|
|
|
@classmethod
|
|
def from_api_response(cls, data: Dict[str, Any]) -> "PositionRating":
|
|
"""
|
|
Create PositionRating from PD API response.
|
|
|
|
Args:
|
|
data: Single position dict from /api/v2/cardpositions response
|
|
|
|
Returns:
|
|
PositionRating instance
|
|
"""
|
|
return cls(
|
|
position=data["position"],
|
|
innings=data["innings"],
|
|
range=data["range"],
|
|
error=data["error"],
|
|
arm=data.get("arm"),
|
|
pb=data.get("pb"),
|
|
overthrow=data.get("overthrow")
|
|
)
|
|
```
|
|
|
|
**Add to BasePlayer class** (around line 42):
|
|
|
|
```python
|
|
class BasePlayer(BaseModel, ABC):
|
|
# ... existing fields ...
|
|
|
|
# Active position rating (loaded for current defensive position)
|
|
active_position_rating: Optional['PositionRating'] = Field(
|
|
None,
|
|
description="Defensive rating for current position"
|
|
)
|
|
```
|
|
|
|
**Update imports** at top of file:
|
|
|
|
```python
|
|
from typing import Optional, List, Dict, Any, TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from app.models.game_models import PositionRating # Forward reference
|
|
```
|
|
|
|
### 2. Add XCheckResult Model to game_models.py
|
|
|
|
**File**: `backend/app/models/game_models.py`
|
|
|
|
**Location**: After PlayResult class (find it in the file)
|
|
|
|
```python
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
from app.config.result_charts import PlayOutcome
|
|
|
|
|
|
@dataclass
|
|
class XCheckResult:
|
|
"""
|
|
Intermediate state for X-Check play resolution.
|
|
|
|
Tracks all dice rolls, table lookups, and conversions from initial
|
|
x-check through final outcome determination.
|
|
|
|
Resolution Flow:
|
|
1. Roll 1d20 + 3d6
|
|
2. Look up base_result from defense table[d20][defender_range]
|
|
3. Apply SPD test if needed (base_result = 'SPD')
|
|
4. Apply G2#/G3# → SI2 conversion if conditions met
|
|
5. Look up error_result from error chart[error_rating][3d6]
|
|
6. Determine final_outcome (may be ERROR if out+error)
|
|
|
|
Attributes:
|
|
position: Position being checked (SS, LF, 3B, etc.)
|
|
d20_roll: Defense range table row selector (1-20)
|
|
d6_roll: Error chart lookup value (3-18, sum of 3d6)
|
|
defender_range: Defender's range rating (1-5, adjusted for playing in)
|
|
defender_error_rating: Defender's error rating (0-25)
|
|
base_result: Initial result from defense table (G1, F2, SI2, SPD, etc.)
|
|
converted_result: Result after SPD/G2#/G3# conversions (may equal base_result)
|
|
error_result: Error type from error chart (NO, E1, E2, E3, RP)
|
|
final_outcome: Final PlayOutcome after all conversions
|
|
defender_id: Player ID of defender
|
|
hit_type: Combined result string for Play.hit_type (e.g. 'single_2_plus_error_1')
|
|
"""
|
|
|
|
position: str
|
|
d20_roll: int
|
|
d6_roll: int
|
|
defender_range: int
|
|
defender_error_rating: int
|
|
defender_id: int
|
|
|
|
base_result: str
|
|
converted_result: str
|
|
error_result: str # 'NO', 'E1', 'E2', 'E3', 'RP'
|
|
|
|
final_outcome: PlayOutcome
|
|
hit_type: str
|
|
|
|
# Optional: SPD test details if applicable
|
|
spd_test_roll: Optional[int] = None
|
|
spd_test_target: Optional[int] = None
|
|
spd_test_passed: Optional[bool] = None
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dict for WebSocket transmission."""
|
|
return {
|
|
'position': self.position,
|
|
'd20_roll': self.d20_roll,
|
|
'd6_roll': self.d6_roll,
|
|
'defender_range': self.defender_range,
|
|
'defender_error_rating': self.defender_error_rating,
|
|
'defender_id': self.defender_id,
|
|
'base_result': self.base_result,
|
|
'converted_result': self.converted_result,
|
|
'error_result': self.error_result,
|
|
'final_outcome': self.final_outcome.value,
|
|
'hit_type': self.hit_type,
|
|
'spd_test': {
|
|
'roll': self.spd_test_roll,
|
|
'target': self.spd_test_target,
|
|
'passed': self.spd_test_passed
|
|
} if self.spd_test_roll else None
|
|
}
|
|
```
|
|
|
|
### 3. Add X_CHECK to PlayOutcome Enum
|
|
|
|
**File**: `backend/app/config/result_charts.py`
|
|
|
|
**Location**: Line 89, after ERROR
|
|
|
|
```python
|
|
class PlayOutcome(str, Enum):
|
|
# ... existing outcomes ...
|
|
|
|
# ==================== Errors ====================
|
|
ERROR = "error"
|
|
|
|
# ==================== X-Check Plays ====================
|
|
# X-Check: Defense-dependent plays requiring range/error rolls
|
|
# Resolution determines actual outcome (hit/out/error)
|
|
X_CHECK = "x_check" # Play.check_pos contains position, resolve via tables
|
|
|
|
# ==================== Interrupt Plays ====================
|
|
# ... rest of enums ...
|
|
```
|
|
|
|
**Add helper method** to PlayOutcome class (around line 199):
|
|
|
|
```python
|
|
def is_x_check(self) -> bool:
|
|
"""Check if outcome requires x-check resolution."""
|
|
return self == self.X_CHECK
|
|
```
|
|
|
|
### 4. Update PlayResult to Include XCheckResult
|
|
|
|
**File**: `backend/app/models/game_models.py`
|
|
|
|
**Location**: In PlayResult dataclass
|
|
|
|
```python
|
|
@dataclass
|
|
class PlayResult:
|
|
"""Result of resolving a single play."""
|
|
|
|
# ... existing fields ...
|
|
|
|
# X-Check details (only populated for x-check plays)
|
|
x_check_details: Optional[XCheckResult] = None
|
|
```
|
|
|
|
### 5. Document Play.check_pos Field
|
|
|
|
**File**: `backend/app/models/db_models.py`
|
|
|
|
**Location**: Line 139, update check_pos field documentation
|
|
|
|
```python
|
|
class Play(Base):
|
|
# ... existing fields ...
|
|
|
|
check_pos = Column(
|
|
String(5),
|
|
nullable=True,
|
|
comment="Position checked for X-Check plays (SS, LF, 3B, etc.). "
|
|
"Non-null indicates this was an X-Check play. "
|
|
"Used only for X-Checks - all other plays leave this null."
|
|
)
|
|
|
|
hit_type = Column(
|
|
String(50),
|
|
nullable=True,
|
|
comment="Detailed hit/out type including errors. Examples: "
|
|
"'single_2_plus_error_1', 'f2_plus_error_2', 'g1_no_error'. "
|
|
"Used primarily for X-Check plays to preserve full resolution details."
|
|
)
|
|
```
|
|
|
|
### 6. Add Redis Cache Key Constants
|
|
|
|
**File**: `backend/app/core/cache.py` (create if doesn't exist)
|
|
|
|
```python
|
|
"""
|
|
Redis cache key patterns and helper functions.
|
|
|
|
Author: Claude
|
|
Date: 2025-11-01
|
|
"""
|
|
|
|
def get_player_positions_cache_key(player_id: int) -> str:
|
|
"""
|
|
Get Redis cache key for player's position ratings.
|
|
|
|
Args:
|
|
player_id: Player ID
|
|
|
|
Returns:
|
|
Cache key string
|
|
|
|
Example:
|
|
>>> get_player_positions_cache_key(10932)
|
|
'player:10932:positions'
|
|
"""
|
|
return f"player:{player_id}:positions"
|
|
|
|
|
|
def get_game_state_cache_key(game_id: int) -> str:
|
|
"""
|
|
Get Redis cache key for game state.
|
|
|
|
Args:
|
|
game_id: Game ID
|
|
|
|
Returns:
|
|
Cache key string
|
|
"""
|
|
return f"game:{game_id}:state"
|
|
```
|
|
|
|
## Testing Requirements
|
|
|
|
1. **Unit Tests**: `tests/models/test_player_models.py`
|
|
- Test PositionRating.from_api_response()
|
|
- Test PositionRating field validation (range 1-5, error 0-25)
|
|
|
|
2. **Unit Tests**: `tests/models/test_game_models.py`
|
|
- Test XCheckResult.to_dict()
|
|
- Test XCheckResult with and without SPD test
|
|
|
|
3. **Integration Tests**: `tests/test_x_check_models.py`
|
|
- Test PlayResult with x_check_details populated
|
|
- Test Play record with check_pos and hit_type
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] PositionRating model added with validation
|
|
- [ ] BasePlayer has active_position_rating field
|
|
- [ ] XCheckResult dataclass complete with to_dict()
|
|
- [ ] PlayOutcome.X_CHECK enum added
|
|
- [ ] PlayOutcome.is_x_check() helper method added
|
|
- [ ] PlayResult.x_check_details field added
|
|
- [ ] Play.check_pos and Play.hit_type documented
|
|
- [ ] Redis cache key helpers created
|
|
- [ ] All unit tests pass
|
|
- [ ] No import errors (verify imports during code review)
|
|
|
|
## Notes
|
|
|
|
- PositionRating will be loaded from PD API at lineup creation (Phase 3E)
|
|
- For SBA games, position ratings come from manual input (semi-auto mode)
|
|
- XCheckResult preserves all resolution steps for debugging and UI display
|
|
- hit_type field allows us to track complex results like "g2_converted_single_2_plus_error_1"
|
|
|
|
## Next Phase
|
|
|
|
After completion, proceed to **Phase 3B: League Config Tables**
|