CLAUDE: Phase 3E-Prep - Refactor GameState to use full LineupPlayerState objects

**Architectural Improvement**: Unified player references in GameState

**Changed**: Make all player references consistent
- BEFORE: current_batter/pitcher/catcher were IDs (int)
- AFTER: current_batter/pitcher/catcher are full LineupPlayerState objects
- Matches pattern of on_first/on_second/on_third (already objects)

**Benefits**:
1. Consistent API - all player references use same type
2. Self-contained GameState - everything needed for resolution
3. No lookups needed - direct access to player data
4. Sets foundation for Phase 3E-Main (adding position ratings)

**Files Modified**:
- app/models/game_models.py: Changed current_batter/pitcher/catcher to objects
- app/core/game_engine.py: Updated _prepare_next_play() to populate full objects
- app/core/state_manager.py: Create placeholder batter on game creation
- tests/unit/models/test_game_models.py: Updated all 27 GameState tests

**Database Operations**:
- No schema changes needed
- Play table still stores IDs (for referential integrity)
- IDs extracted from objects when saving: state.current_batter.lineup_id

**Testing**:
- All 27 GameState tests passing
- No regressions in existing functionality
- Type checking passes

**Next**: Phase 3E-Main - Add PositionRating dataclass and load ratings at game start

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-11-03 14:11:40 -06:00
parent 7417a3f450
commit d560844704
5 changed files with 578 additions and 101 deletions

View File

@ -0,0 +1,435 @@
# GameState Refactor Plan - Self-Contained State with Position Ratings
**Created**: 2025-11-03
**Status**: Ready for Implementation
**Priority**: High - Prerequisite for Phase 3E
## Problem Statement
### Current Architectural Inconsistency
GameState has an inconsistency in how it references players:
```python
# FULL OBJECTS for runners
on_first: Optional[LineupPlayerState] = None
on_second: Optional[LineupPlayerState] = None
on_third: Optional[LineupPlayerState] = None
# ONLY IDs for active players
current_batter_lineup_id: int
current_pitcher_lineup_id: Optional[int] = None
current_catcher_lineup_id: Optional[int] = None
```
This creates several problems:
1. **Inconsistent API**: Runners are objects, but batter/pitcher/catcher are IDs
2. **Extra lookups needed**: PlayResolver must look up lineup to get player details
3. **Coupling**: PlayResolver depends on StateManager for player lookups
4. **Missing data**: No direct access to position ratings for X-Check resolution
## Proposed Solution
### Phase 1: Make All Player References Consistent
Change `current_batter/pitcher/catcher_lineup_id` to full `LineupPlayerState` objects:
```python
class GameState(BaseModel):
# ... existing fields
# Runners (already full objects) ✅
on_first: Optional[LineupPlayerState] = None
on_second: Optional[LineupPlayerState] = None
on_third: Optional[LineupPlayerState] = None
# CHANGE: Make these full objects too
current_batter: LineupPlayerState
current_pitcher: Optional[LineupPlayerState] = None
current_catcher: Optional[LineupPlayerState] = None
# KEEP for database persistence (Play table needs IDs)
current_on_base_code: int = Field(default=0, ge=0)
```
### Phase 2: Add Position Ratings to LineupPlayerState
Enhance `LineupPlayerState` to hold position ratings loaded at game start:
```python
# In app/models/player_models.py
@dataclass
class PositionRating:
"""Defensive ratings for a player at a specific position."""
position: str # Position code (SS, 3B, LF, etc.)
range: int # Range rating (1-5)
error: int # Error rating (0-88 depending on position)
arm: int # Arm strength (1-5)
speed: Optional[int] = None # Speed rating (0-20) for SPD tests
# In app/models/game_models.py
class LineupPlayerState(BaseModel):
lineup_id: int
card_id: int
position: str
batting_order: Optional[int] = None
is_active: bool = True
# NEW: Position rating (loaded at game start for PD league)
position_rating: Optional[PositionRating] = None
```
### Phase 3: Load Position Ratings at Game Start
Create PD API client and load ratings when lineups are created:
```python
# In app/services/pd_api_client.py (NEW)
async def fetch_position_rating(player_id: int, position: str) -> PositionRating:
"""Fetch position rating from PD API."""
url = f"{PD_API_BASE}/cardpositions/player/{player_id}"
async with httpx.AsyncClient() as client:
response = await client.get(url)
data = response.json()
# Find rating for this position
for pos_data in data.get('positions', []):
if pos_data['position'] == position:
return PositionRating(
position=position,
range=pos_data['range'],
error=pos_data['error'],
arm=pos_data['arm'],
speed=pos_data.get('speed')
)
return None
# In game_engine.py - at lineup creation
async def _load_lineup_with_ratings(self, lineup_entries, league_id):
"""Load lineup and attach position ratings for PD league."""
lineup_players = []
for entry in lineup_entries:
player = LineupPlayerState(
lineup_id=entry.id,
card_id=entry.card_id,
position=entry.position,
batting_order=entry.batting_order
)
# Load position rating for PD league
if league_id == 'pd':
rating = await pd_api.fetch_position_rating(
entry.card_id,
entry.position
)
player.position_rating = rating
lineup_players.append(player)
return lineup_players
```
## Benefits
### 1. Architectural Consistency ✅
- All player references in GameState are `LineupPlayerState` objects
- Uniform API for accessing player data
- More intuitive to work with
### 2. Self-Contained State ✅
- GameState has everything needed for play resolution
- No external lookups required during resolution
- Truly represents "complete game state"
### 3. Simplified PlayResolver ✅
```python
# BEFORE (needs StateManager)
def _resolve_x_check(self, state, state_manager, ...):
lineup = state_manager.get_lineup(state.game_id, team_id)
defender = next(p for p in lineup.players if p.position == position)
# Still need to fetch rating somehow...
# AFTER (fully self-contained)
def _resolve_x_check(self, state, ...):
lineup = self.state_manager.get_lineup(state.game_id, team_id)
defender = next(p for p in lineup.players if p.position == position)
# Direct access to everything needed!
defender_range = defender.position_rating.range
defender_error = defender.position_rating.error
batter_speed = state.current_batter.position_rating.speed
```
### 4. Better Performance ✅
- Ratings loaded **once** at game start
- No API calls during play resolution
- No Redis lookups during plays
- Faster resolution (<500ms target easily met)
### 5. Redis Still Useful ✅
- Cache ratings for future games with same players
- Reduce PD API load
- Fast game recovery (load ratings from cache)
## Trade-offs
### Memory Impact
- `LineupPlayerState` currently: ~100 bytes
- Adding `PositionRating`: +50 bytes
- Per game (18 players): 18 × 150 = **~2.7KB total**
- **Verdict**: Negligible ✅
### Complexity
- **Loading**: Slightly more complex at game start (API calls)
- **Resolution**: Much simpler (direct access)
- **Overall**: Net reduction in complexity ✅
### Database
- Play table still stores `batter_id`, `pitcher_id`, etc. (IDs)
- No schema changes needed ✅
- IDs used for referential integrity, objects used in memory
## Implementation Plan
### Phase 3E-Prep: Refactor GameState (2-3 hours)
**Goal**: Make all player references consistent (full objects)
**Files to Modify**:
1. `backend/app/models/game_models.py`
- Change fields: `current_batter`, `current_pitcher`, `current_catcher`
- Remove: `current_batter_lineup_id`, etc. (IDs)
- Update validators if needed
2. `backend/app/core/game_engine.py`
- Update `_prepare_next_play()` to set full objects
- Change from: `state.current_batter_lineup_id = batter.id`
- Change to: `state.current_batter = LineupPlayerState(...)`
3. `backend/app/core/play_resolver.py`
- Update any references to `state.current_batter_lineup_id`
- Change to: `state.current_batter.lineup_id`
4. `backend/app/database/operations.py`
- When saving Play, extract IDs from objects
- Change from: `batter_id=state.current_batter_lineup_id`
- Change to: `batter_id=state.current_batter.lineup_id`
5. Update all tests
- `tests/unit/models/test_game_models.py`
- `tests/unit/core/test_game_engine.py`
- `tests/integration/test_state_persistence.py`
**Acceptance Criteria**:
- [ ] All player references in GameState are `LineupPlayerState` objects
- [ ] All tests passing
- [ ] No regressions in existing functionality
- [ ] Type checking passes
**Test Command**:
```bash
pytest tests/unit/models/test_game_models.py -v
pytest tests/unit/core/test_game_engine.py -v
pytest tests/integration/ -v
```
---
### Phase 3E-Main: Add Position Ratings (3-4 hours)
**Goal**: Load and attach position ratings at game start
**Files to Create**:
1. `backend/app/services/pd_api_client.py` (NEW)
- `fetch_position_rating(player_id, position)` - Get rating from PD API
- `fetch_all_positions(player_id)` - Get all positions for a player
- Async HTTP client with proper error handling
2. `backend/app/services/position_rating_cache.py` (NEW)
- Redis caching wrapper
- Cache key: `position_rating:{player_id}:{position}`
- TTL: 24 hours
- Graceful degradation if Redis unavailable
**Files to Modify**:
1. `backend/app/models/player_models.py`
- Add `PositionRating` dataclass
- Fields: position, range, error, arm, speed
2. `backend/app/models/game_models.py`
- Add `position_rating: Optional[PositionRating]` to `LineupPlayerState`
3. `backend/app/core/game_engine.py`
- Add `_load_lineup_with_ratings()` method
- Call during lineup creation
- Handle PD vs SBA (only load for PD)
4. `backend/app/config/settings.py`
- Add `PD_API_URL` setting
- Add `PD_API_TOKEN` setting (if needed)
**Tests to Create**:
1. `tests/unit/services/test_pd_api_client.py`
- Mock HTTP responses
- Test successful fetch
- Test error handling
- Test API response parsing
2. `tests/unit/services/test_position_rating_cache.py`
- Test cache hit/miss
- Test TTL expiration
- Test graceful degradation
3. `tests/integration/test_lineup_rating_loading.py`
- End-to-end test of lineup creation with ratings
- Verify ratings attached to LineupPlayerState
**Acceptance Criteria**:
- [ ] PD API client created and tested
- [ ] Redis caching implemented
- [ ] Ratings loaded at game start for PD league
- [ ] SBA league unaffected (no ratings loaded)
- [ ] All tests passing
- [ ] Graceful handling of API failures
**Test Command**:
```bash
pytest tests/unit/services/test_pd_api_client.py -v
pytest tests/unit/services/test_position_rating_cache.py -v
pytest tests/integration/test_lineup_rating_loading.py -v
```
---
### Phase 3E-Final: Update X-Check Resolution (1 hour)
**Goal**: Use attached position ratings in X-Check resolution
**Files to Modify**:
1. `backend/app/core/play_resolver.py`
- Update `_resolve_x_check()` to use defender ratings
- Remove placeholders:
```python
# OLD (placeholder)
defender_range = 3
defender_error_rating = 10
# NEW (from GameState)
defender_range = defender.position_rating.range
defender_error_rating = defender.position_rating.error
```
- Update SPD test to use batter speed
- Add fallback for missing ratings (use league defaults)
2. Update tests
- Add position ratings to test fixtures
- Test X-Check with actual ratings
- Test fallback behavior
**Acceptance Criteria**:
- [ ] X-Check uses actual position ratings
- [ ] SPD test uses actual batter speed
- [ ] Graceful fallback for missing ratings
- [ ] All PlayResolver tests passing
- [ ] Integration test with full flow
**Test Command**:
```bash
pytest tests/unit/core/test_play_resolver.py -v
pytest tests/integration/test_x_check_full_flow.py -v
```
---
## Verification Steps
After all phases complete:
1. **Unit Tests**:
```bash
pytest tests/unit/ -v
```
Expected: All tests passing
2. **Integration Tests**:
```bash
pytest tests/integration/ -v
```
Expected: All tests passing
3. **Type Checking**:
```bash
mypy app/
```
Expected: No new type errors
4. **Manual Testing** (Terminal Client):
```bash
python -m terminal_client
> new_game
> start_game
> defensive
> offensive
> resolve # Should use actual ratings for X-Check
> status
```
5. **Memory Usage**:
- Check GameState size in memory
- Expected: < 5KB per game (negligible increase)
6. **Performance**:
- Time play resolution
- Expected: < 500ms (should be faster with direct access)
## Success Criteria
Phase 3E will be **100% complete** when:
- [ ] All player references in GameState are consistent (full objects)
- [ ] Position ratings loaded at game start (PD league)
- [ ] X-Check resolution uses actual ratings (no placeholders)
- [ ] Redis caching implemented and working
- [ ] All unit tests passing (400+ tests)
- [ ] All integration tests passing (30+ tests)
- [ ] Type checking passes with no new errors
- [ ] Documentation updated (CLAUDE.md, NEXT_SESSION.md)
- [ ] Performance targets met (< 500ms resolution)
- [ ] Memory usage acceptable (< 5KB increase per game)
## Rollback Plan
If issues arise during implementation:
1. **Phase 3E-Prep issues**:
- Revert game_models.py changes
- Keep using IDs instead of objects
- Phase 3E-Main can still proceed with lookups
2. **Phase 3E-Main issues**:
- Skip position rating loading
- Use default/placeholder ratings
- Complete Phase 3E-Prep first (still valuable)
3. **Phase 3E-Final issues**:
- Keep placeholders in PlayResolver
- Complete position rating loading
- Update resolution in separate PR
## Related Documentation
- **Phase 3 Overview**: `./.claude/implementation/PHASE_3_OVERVIEW.md`
- **Next Session Plan**: `./.claude/implementation/NEXT_SESSION.md`
- **Game Models**: `../backend/app/models/CLAUDE.md`
- **Play Resolver**: `../backend/app/core/play_resolver.py` (lines 589-758)
---
**Estimated Total Time**: 6-8 hours (spread across 3 phases)
**Priority**: High - This refactor makes Phase 3E cleaner and more maintainable
**Blocking Work**: None - can start immediately
**Next Milestone**: Phase 3F (Testing & Integration)

View File

@ -113,7 +113,7 @@ class GameEngine:
) )
logger.info( logger.info(
f"Started game {game_id} - First batter: lineup_id={state.current_batter_lineup_id}" f"Started game {game_id} - First batter: lineup_id={state.current_batter.lineup_id}"
) )
return state return state
@ -622,26 +622,8 @@ class GameEngine:
# Add batter if reached base # Add batter if reached base
if result.batter_result and result.batter_result < 4: if result.batter_result and result.batter_result < 4:
# Look up the actual batter from cached lineup # GameState now has the full batter object (set by _prepare_next_play)
batting_team_id = state.away_team_id if state.half == "top" else state.home_team_id batter = state.current_batter
batting_lineup = state_manager.get_lineup(state.game_id, batting_team_id)
batter = None
if batting_lineup and state.current_batter_lineup_id:
# Find the batter in the lineup
batter = batting_lineup.get_player_by_lineup_id(state.current_batter_lineup_id)
if not batter:
# Fallback: create minimal LineupPlayerState
# This shouldn't happen if _prepare_next_play was called correctly
from app.models.game_models import LineupPlayerState
logger.warning(f"Could not find batter lineup_id={state.current_batter_lineup_id} in cached lineup, using fallback")
batter = LineupPlayerState(
lineup_id=state.current_batter_lineup_id or 0,
card_id=0,
position="DH", # Use DH as fallback position
batting_order=None
)
if result.batter_result == 1: if result.batter_result == 1:
new_first = batter new_first = batter
@ -788,24 +770,33 @@ class GameEngine:
key=lambda x: x.batting_order or 0 key=lambda x: x.batting_order or 0
) )
if current_idx < len(batting_order): if current_idx < len(batting_order):
state.current_batter_lineup_id = batting_order[current_idx].lineup_id state.current_batter = batting_order[current_idx]
else: else:
state.current_batter_lineup_id = None # Create placeholder - this shouldn't happen in normal gameplay
state.current_batter = LineupPlayerState(
lineup_id=0,
card_id=0,
position="DH",
batting_order=None
)
logger.warning(f"Batter index {current_idx} out of range for batting order") logger.warning(f"Batter index {current_idx} out of range for batting order")
else: else:
state.current_batter_lineup_id = None # Create placeholder - this shouldn't happen in normal gameplay
state.current_batter = LineupPlayerState(
lineup_id=0,
card_id=0,
position="DH",
batting_order=None
)
logger.warning(f"No batting lineup found for team {batting_team}") logger.warning(f"No batting lineup found for team {batting_team}")
# Pitcher and catcher: find by position from cached lineup # Pitcher and catcher: find by position from cached lineup
if fielding_lineup_state: if fielding_lineup_state:
pitcher = next((p for p in fielding_lineup_state.players if p.position == "P"), None) state.current_pitcher = next((p for p in fielding_lineup_state.players if p.position == "P"), None)
state.current_pitcher_lineup_id = pitcher.lineup_id if pitcher else None state.current_catcher = next((p for p in fielding_lineup_state.players if p.position == "C"), None)
catcher = next((p for p in fielding_lineup_state.players if p.position == "C"), None)
state.current_catcher_lineup_id = catcher.lineup_id if catcher else None
else: else:
state.current_pitcher_lineup_id = None state.current_pitcher = None
state.current_catcher_lineup_id = None state.current_catcher = None
# Calculate on_base_code from current runners (bit field) # Calculate on_base_code from current runners (bit field)
state.current_on_base_code = 0 state.current_on_base_code = 0
@ -817,9 +808,9 @@ class GameEngine:
state.current_on_base_code |= 4 # Bit 2: third base state.current_on_base_code |= 4 # Bit 2: third base
logger.debug( logger.debug(
f"Prepared next play: batter={state.current_batter_lineup_id}, " f"Prepared next play: batter={state.current_batter.lineup_id}, "
f"pitcher={state.current_pitcher_lineup_id}, " f"pitcher={state.current_pitcher.lineup_id if state.current_pitcher else None}, "
f"catcher={state.current_catcher_lineup_id}, " f"catcher={state.current_catcher.lineup_id if state.current_catcher else None}, "
f"on_base_code={state.current_on_base_code}" f"on_base_code={state.current_on_base_code}"
) )
@ -861,9 +852,10 @@ class GameEngine:
ValueError: If required player IDs are missing ValueError: If required player IDs are missing
""" """
# Use snapshot from GameState (set by _prepare_next_play) # Use snapshot from GameState (set by _prepare_next_play)
batter_id = state.current_batter_lineup_id # Extract IDs from objects for database persistence
pitcher_id = state.current_pitcher_lineup_id batter_id = state.current_batter.lineup_id
catcher_id = state.current_catcher_lineup_id pitcher_id = state.current_pitcher.lineup_id if state.current_pitcher else None
catcher_id = state.current_catcher.lineup_id if state.current_catcher else None
on_base_code = state.current_on_base_code on_base_code = state.current_on_base_code
# VERIFY required fields are present # VERIFY required fields are present

View File

@ -82,6 +82,15 @@ class StateManager:
logger.info(f"Creating game state for {game_id} ({league_id} league, auto_mode={auto_mode})") logger.info(f"Creating game state for {game_id} ({league_id} league, auto_mode={auto_mode})")
# Create placeholder batter (will be set by _prepare_next_play() when game starts)
from app.models.game_models import LineupPlayerState
placeholder_batter = LineupPlayerState(
lineup_id=0,
card_id=0,
position="DH",
batting_order=None
)
state = GameState( state = GameState(
game_id=game_id, game_id=game_id,
league_id=league_id, league_id=league_id,
@ -90,7 +99,7 @@ class StateManager:
home_team_is_ai=home_team_is_ai, home_team_is_ai=home_team_is_ai,
away_team_is_ai=away_team_is_ai, away_team_is_ai=away_team_is_ai,
auto_mode=auto_mode, auto_mode=auto_mode,
current_batter_lineup_id=0 # Will be set by _prepare_next_play() when game starts current_batter=placeholder_batter # Will be replaced by _prepare_next_play() when game starts
) )
self._states[game_id] = state self._states[game_id] = state

View File

@ -370,9 +370,10 @@ class GameState(BaseModel):
# Current play snapshot (set by _prepare_next_play) # Current play snapshot (set by _prepare_next_play)
# These capture the state BEFORE each play for accurate record-keeping # These capture the state BEFORE each play for accurate record-keeping
current_batter_lineup_id: int # Changed to full objects for consistency with on_first/on_second/on_third
current_pitcher_lineup_id: Optional[int] = None current_batter: LineupPlayerState
current_catcher_lineup_id: Optional[int] = None current_pitcher: Optional[LineupPlayerState] = None
current_catcher: Optional[LineupPlayerState] = None
current_on_base_code: int = Field(default=0, ge=0) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded current_on_base_code: int = Field(default=0, ge=0) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded
# Decision tracking # Decision tracking
@ -628,9 +629,9 @@ class GameState(BaseModel):
"on_third": None, "on_third": None,
"away_team_batter_idx": 3, "away_team_batter_idx": 3,
"home_team_batter_idx": 5, "home_team_batter_idx": 5,
"current_batter_lineup_id": 8, "current_batter": {"lineup_id": 8, "card_id": 125, "position": "RF", "batting_order": 4},
"current_pitcher_lineup_id": 10, "current_pitcher": {"lineup_id": 10, "card_id": 130, "position": "P", "batting_order": 9},
"current_catcher_lineup_id": 11, "current_catcher": {"lineup_id": 11, "card_id": 131, "position": "C", "batting_order": 2},
"current_on_base_code": 2, "current_on_base_code": 2,
"pending_decision": None, "pending_decision": None,
"decisions_this_play": {}, "decisions_this_play": {},

View File

@ -20,6 +20,46 @@ from app.models.game_models import (
) )
# ============================================================================
# TEST FIXTURES
# ============================================================================
@pytest.fixture
def sample_batter():
"""Create a sample batter for testing GameState"""
return LineupPlayerState(
lineup_id=1,
card_id=100,
position="RF",
batting_order=3,
is_active=True
)
@pytest.fixture
def sample_pitcher():
"""Create a sample pitcher for testing GameState"""
return LineupPlayerState(
lineup_id=10,
card_id=200,
position="P",
batting_order=9,
is_active=True
)
@pytest.fixture
def sample_catcher():
"""Create a sample catcher for testing GameState"""
return LineupPlayerState(
lineup_id=2,
card_id=101,
position="C",
batting_order=2,
is_active=True
)
# ============================================================================ # ============================================================================
# LINEUP TESTS # LINEUP TESTS
# ============================================================================ # ============================================================================
@ -276,7 +316,7 @@ class TestOffensiveDecision:
class TestGameState: class TestGameState:
"""Tests for GameState model""" """Tests for GameState model"""
def test_create_game_state_minimal(self): def test_create_game_state_minimal(self, sample_batter):
"""Test creating game state with minimal fields""" """Test creating game state with minimal fields"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -284,7 +324,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1 current_batter=sample_batter
) )
assert state.game_id == game_id assert state.game_id == game_id
assert state.league_id == "sba" assert state.league_id == "sba"
@ -297,7 +337,7 @@ class TestGameState:
assert state.home_score == 0 assert state.home_score == 0
assert state.away_score == 0 assert state.away_score == 0
def test_game_state_valid_league_ids(self): def test_game_state_valid_league_ids(self, sample_batter):
"""Test valid league IDs""" """Test valid league IDs"""
game_id = uuid4() game_id = uuid4()
for league in ['sba', 'pd']: for league in ['sba', 'pd']:
@ -306,11 +346,11 @@ class TestGameState:
league_id=league, league_id=league,
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1 current_batter=sample_batter
) )
assert state.league_id == league assert state.league_id == league
def test_game_state_invalid_league_id(self): def test_game_state_invalid_league_id(self, sample_batter):
"""Test that invalid league ID raises error""" """Test that invalid league ID raises error"""
game_id = uuid4() game_id = uuid4()
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@ -319,10 +359,10 @@ class TestGameState:
league_id="invalid", league_id="invalid",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1 current_batter=sample_batter
) )
def test_game_state_valid_statuses(self): def test_game_state_valid_statuses(self, sample_batter):
"""Test valid game statuses""" """Test valid game statuses"""
game_id = uuid4() game_id = uuid4()
for status in ['pending', 'active', 'paused', 'completed']: for status in ['pending', 'active', 'paused', 'completed']:
@ -331,12 +371,12 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
status=status status=status
) )
assert state.status == status assert state.status == status
def test_game_state_invalid_status(self): def test_game_state_invalid_status(self, sample_batter):
"""Test that invalid status raises error""" """Test that invalid status raises error"""
game_id = uuid4() game_id = uuid4()
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@ -345,11 +385,11 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
status="invalid" status="invalid"
) )
def test_game_state_valid_halves(self): def test_game_state_valid_halves(self, sample_batter):
"""Test valid inning halves""" """Test valid inning halves"""
game_id = uuid4() game_id = uuid4()
for half in ['top', 'bottom']: for half in ['top', 'bottom']:
@ -358,12 +398,12 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half=half half=half
) )
assert state.half == half assert state.half == half
def test_game_state_invalid_half(self): def test_game_state_invalid_half(self, sample_batter):
"""Test that invalid half raises error""" """Test that invalid half raises error"""
game_id = uuid4() game_id = uuid4()
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@ -372,11 +412,11 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half="middle" half="middle"
) )
def test_game_state_outs_validation(self): def test_game_state_outs_validation(self, sample_batter):
"""Test outs validation (0-2)""" """Test outs validation (0-2)"""
game_id = uuid4() game_id = uuid4()
@ -387,7 +427,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
outs=outs outs=outs
) )
assert state.outs == outs assert state.outs == outs
@ -399,11 +439,11 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
outs=3 outs=3
) )
def test_get_batting_team_id(self): def test_get_batting_team_id(self, sample_batter):
"""Test getting batting team ID""" """Test getting batting team ID"""
game_id = uuid4() game_id = uuid4()
@ -413,7 +453,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half="top" half="top"
) )
assert state.get_batting_team_id() == 2 assert state.get_batting_team_id() == 2
@ -422,7 +462,7 @@ class TestGameState:
state.half = "bottom" state.half = "bottom"
assert state.get_batting_team_id() == 1 assert state.get_batting_team_id() == 1
def test_get_fielding_team_id(self): def test_get_fielding_team_id(self, sample_batter):
"""Test getting fielding team ID""" """Test getting fielding team ID"""
game_id = uuid4() game_id = uuid4()
@ -432,7 +472,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half="top" half="top"
) )
assert state.get_fielding_team_id() == 1 assert state.get_fielding_team_id() == 1
@ -441,7 +481,7 @@ class TestGameState:
state.half = "bottom" state.half = "bottom"
assert state.get_fielding_team_id() == 2 assert state.get_fielding_team_id() == 2
def test_is_runner_on_base(self): def test_is_runner_on_base(self, sample_batter):
"""Test checking for runners on specific bases""" """Test checking for runners on specific bases"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -449,7 +489,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3) on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3)
) )
@ -458,7 +498,7 @@ class TestGameState:
assert state.is_runner_on_second() is False assert state.is_runner_on_second() is False
assert state.is_runner_on_third() is True assert state.is_runner_on_third() is True
def test_get_runner_at_base(self): def test_get_runner_at_base(self, sample_batter):
"""Test getting runner at specific base""" """Test getting runner at specific base"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -466,7 +506,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2) on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2)
) )
@ -478,7 +518,7 @@ class TestGameState:
runner = state.get_runner_at_base(3) runner = state.get_runner_at_base(3)
assert runner is None assert runner is None
def test_bases_occupied(self): def test_bases_occupied(self, sample_batter):
"""Test getting list of occupied bases""" """Test getting list of occupied bases"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -486,7 +526,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3) on_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3)
) )
@ -494,7 +534,7 @@ class TestGameState:
bases = state.bases_occupied() bases = state.bases_occupied()
assert bases == [1, 3] assert bases == [1, 3]
def test_clear_bases(self): def test_clear_bases(self, sample_batter):
"""Test clearing all runners""" """Test clearing all runners"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -502,7 +542,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1), on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1),
on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2) on_second=LineupPlayerState(lineup_id=2, card_id=102, position="RF", batting_order=2)
) )
@ -514,7 +554,7 @@ class TestGameState:
assert state.on_second is None assert state.on_second is None
assert state.on_third is None assert state.on_third is None
def test_add_runner(self): def test_add_runner(self, sample_batter):
"""Test adding a runner to a base""" """Test adding a runner to a base"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -522,7 +562,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1 current_batter=sample_batter
) )
player = LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) player = LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
@ -530,7 +570,7 @@ class TestGameState:
assert len(state.get_all_runners()) == 1 assert len(state.get_all_runners()) == 1
assert state.is_runner_on_first() is True assert state.is_runner_on_first() is True
def test_add_runner_replaces_existing(self): def test_add_runner_replaces_existing(self, sample_batter):
"""Test that adding runner to occupied base replaces existing""" """Test that adding runner to occupied base replaces existing"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -538,7 +578,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
) )
@ -548,7 +588,7 @@ class TestGameState:
runner = state.get_runner_at_base(1) runner = state.get_runner_at_base(1)
assert runner.lineup_id == 2 # New runner replaced old assert runner.lineup_id == 2 # New runner replaced old
def test_advance_runner_to_base(self): def test_advance_runner_to_base(self, sample_batter):
"""Test advancing runner to another base""" """Test advancing runner to another base"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -556,7 +596,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) on_first=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
) )
@ -564,7 +604,7 @@ class TestGameState:
assert state.is_runner_on_first() is False assert state.is_runner_on_first() is False
assert state.is_runner_on_second() is True assert state.is_runner_on_second() is True
def test_advance_runner_to_home(self): def test_advance_runner_to_home(self, sample_batter):
"""Test advancing runner to home (scoring)""" """Test advancing runner to home (scoring)"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -572,7 +612,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half="top", half="top",
on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
) )
@ -582,7 +622,7 @@ class TestGameState:
assert len(state.get_all_runners()) == 0 # Runner removed from bases assert len(state.get_all_runners()) == 0 # Runner removed from bases
assert state.away_score == initial_score + 1 # Score increased assert state.away_score == initial_score + 1 # Score increased
def test_advance_runner_scores_correct_team(self): def test_advance_runner_scores_correct_team(self, sample_batter):
"""Test that scoring increments correct team's score""" """Test that scoring increments correct team's score"""
game_id = uuid4() game_id = uuid4()
@ -592,7 +632,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half="top", half="top",
on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1) on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
) )
@ -606,7 +646,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
half="bottom", half="bottom",
on_third=LineupPlayerState(lineup_id=5, card_id=105, position="RF", batting_order=5) on_third=LineupPlayerState(lineup_id=5, card_id=105, position="RF", batting_order=5)
) )
@ -614,7 +654,7 @@ class TestGameState:
assert state.home_score == 1 assert state.home_score == 1
assert state.away_score == 0 assert state.away_score == 0
def test_increment_outs(self): def test_increment_outs(self, sample_batter):
"""Test incrementing outs""" """Test incrementing outs"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -622,7 +662,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
outs=0 outs=0
) )
@ -635,7 +675,7 @@ class TestGameState:
assert state.increment_outs() is True # 3 outs - end of inning assert state.increment_outs() is True # 3 outs - end of inning
assert state.outs == 3 assert state.outs == 3
def test_end_half_inning_top_to_bottom(self): def test_end_half_inning_top_to_bottom(self, sample_batter):
"""Test ending top of inning goes to bottom""" """Test ending top of inning goes to bottom"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -643,7 +683,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=3, inning=3,
half="top", half="top",
outs=2, outs=2,
@ -656,7 +696,7 @@ class TestGameState:
assert state.outs == 0 # Outs reset assert state.outs == 0 # Outs reset
assert len(state.get_all_runners()) == 0 # Bases cleared assert len(state.get_all_runners()) == 0 # Bases cleared
def test_end_half_inning_bottom_to_next_top(self): def test_end_half_inning_bottom_to_next_top(self, sample_batter):
"""Test ending bottom of inning goes to next inning top""" """Test ending bottom of inning goes to next inning top"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -664,7 +704,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=3, inning=3,
half="bottom", half="bottom",
outs=2, outs=2,
@ -677,7 +717,7 @@ class TestGameState:
assert state.outs == 0 # Outs reset assert state.outs == 0 # Outs reset
assert len(state.get_all_runners()) == 0 # Bases cleared assert len(state.get_all_runners()) == 0 # Bases cleared
def test_is_game_over_early_innings(self): def test_is_game_over_early_innings(self, sample_batter):
"""Test game is not over in early innings""" """Test game is not over in early innings"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -685,7 +725,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=5, inning=5,
half="bottom", half="bottom",
home_score=5, home_score=5,
@ -694,7 +734,7 @@ class TestGameState:
assert state.is_game_over() is False assert state.is_game_over() is False
def test_is_game_over_bottom_ninth_home_ahead(self): def test_is_game_over_bottom_ninth_home_ahead(self, sample_batter):
"""Test game over when home team ahead in bottom 9th""" """Test game over when home team ahead in bottom 9th"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -702,7 +742,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=9, inning=9,
half="bottom", half="bottom",
home_score=5, home_score=5,
@ -711,7 +751,7 @@ class TestGameState:
assert state.is_game_over() is True assert state.is_game_over() is True
def test_is_game_over_bottom_ninth_tied(self): def test_is_game_over_bottom_ninth_tied(self, sample_batter):
"""Test game continues when tied in bottom 9th""" """Test game continues when tied in bottom 9th"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -719,7 +759,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=9, inning=9,
half="bottom", half="bottom",
home_score=2, home_score=2,
@ -728,7 +768,7 @@ class TestGameState:
assert state.is_game_over() is False assert state.is_game_over() is False
def test_is_game_over_extra_innings_walkoff(self): def test_is_game_over_extra_innings_walkoff(self, sample_batter):
"""Test game over on walk-off in extra innings""" """Test game over on walk-off in extra innings"""
game_id = uuid4() game_id = uuid4()
state = GameState( state = GameState(
@ -736,7 +776,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=10, inning=10,
half="bottom", half="bottom",
home_score=5, home_score=5,
@ -745,7 +785,7 @@ class TestGameState:
assert state.is_game_over() is True assert state.is_game_over() is True
def test_is_game_over_after_top_ninth_home_ahead(self): def test_is_game_over_after_top_ninth_home_ahead(self, sample_batter):
"""Test game over at start of bottom 9th when away team ahead""" """Test game over at start of bottom 9th when away team ahead"""
game_id = uuid4() game_id = uuid4()
# Bottom of 9th, away team ahead - home team can't catch up # Bottom of 9th, away team ahead - home team can't catch up
@ -756,7 +796,7 @@ class TestGameState:
league_id="sba", league_id="sba",
home_team_id=1, home_team_id=1,
away_team_id=2, away_team_id=2,
current_batter_lineup_id=1, current_batter=sample_batter,
inning=9, inning=9,
half="bottom", half="bottom",
outs=0, outs=0,