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:
parent
7417a3f450
commit
d560844704
435
.claude/implementation/GAMESTATE_REFACTOR_PLAN.md
Normal file
435
.claude/implementation/GAMESTATE_REFACTOR_PLAN.md
Normal 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)
|
||||
@ -113,7 +113,7 @@ class GameEngine:
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@ -622,26 +622,8 @@ class GameEngine:
|
||||
|
||||
# Add batter if reached base
|
||||
if result.batter_result and result.batter_result < 4:
|
||||
# Look up the actual batter from cached lineup
|
||||
batting_team_id = state.away_team_id if state.half == "top" else state.home_team_id
|
||||
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
|
||||
)
|
||||
# GameState now has the full batter object (set by _prepare_next_play)
|
||||
batter = state.current_batter
|
||||
|
||||
if result.batter_result == 1:
|
||||
new_first = batter
|
||||
@ -788,24 +770,33 @@ class GameEngine:
|
||||
key=lambda x: x.batting_order or 0
|
||||
)
|
||||
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:
|
||||
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")
|
||||
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}")
|
||||
|
||||
# Pitcher and catcher: find by position from cached lineup
|
||||
if fielding_lineup_state:
|
||||
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
|
||||
|
||||
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
|
||||
state.current_pitcher = next((p for p in fielding_lineup_state.players if p.position == "P"), None)
|
||||
state.current_catcher = next((p for p in fielding_lineup_state.players if p.position == "C"), None)
|
||||
else:
|
||||
state.current_pitcher_lineup_id = None
|
||||
state.current_catcher_lineup_id = None
|
||||
state.current_pitcher = None
|
||||
state.current_catcher = None
|
||||
|
||||
# Calculate on_base_code from current runners (bit field)
|
||||
state.current_on_base_code = 0
|
||||
@ -817,9 +808,9 @@ class GameEngine:
|
||||
state.current_on_base_code |= 4 # Bit 2: third base
|
||||
|
||||
logger.debug(
|
||||
f"Prepared next play: batter={state.current_batter_lineup_id}, "
|
||||
f"pitcher={state.current_pitcher_lineup_id}, "
|
||||
f"catcher={state.current_catcher_lineup_id}, "
|
||||
f"Prepared next play: batter={state.current_batter.lineup_id}, "
|
||||
f"pitcher={state.current_pitcher.lineup_id if state.current_pitcher else None}, "
|
||||
f"catcher={state.current_catcher.lineup_id if state.current_catcher else None}, "
|
||||
f"on_base_code={state.current_on_base_code}"
|
||||
)
|
||||
|
||||
@ -861,9 +852,10 @@ class GameEngine:
|
||||
ValueError: If required player IDs are missing
|
||||
"""
|
||||
# Use snapshot from GameState (set by _prepare_next_play)
|
||||
batter_id = state.current_batter_lineup_id
|
||||
pitcher_id = state.current_pitcher_lineup_id
|
||||
catcher_id = state.current_catcher_lineup_id
|
||||
# Extract IDs from objects for database persistence
|
||||
batter_id = state.current_batter.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
|
||||
|
||||
# VERIFY required fields are present
|
||||
|
||||
@ -82,6 +82,15 @@ class StateManager:
|
||||
|
||||
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(
|
||||
game_id=game_id,
|
||||
league_id=league_id,
|
||||
@ -90,7 +99,7 @@ class StateManager:
|
||||
home_team_is_ai=home_team_is_ai,
|
||||
away_team_is_ai=away_team_is_ai,
|
||||
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
|
||||
|
||||
@ -370,9 +370,10 @@ class GameState(BaseModel):
|
||||
|
||||
# Current play snapshot (set by _prepare_next_play)
|
||||
# These capture the state BEFORE each play for accurate record-keeping
|
||||
current_batter_lineup_id: int
|
||||
current_pitcher_lineup_id: Optional[int] = None
|
||||
current_catcher_lineup_id: Optional[int] = None
|
||||
# Changed to full objects for consistency with on_first/on_second/on_third
|
||||
current_batter: LineupPlayerState
|
||||
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
|
||||
|
||||
# Decision tracking
|
||||
@ -628,9 +629,9 @@ class GameState(BaseModel):
|
||||
"on_third": None,
|
||||
"away_team_batter_idx": 3,
|
||||
"home_team_batter_idx": 5,
|
||||
"current_batter_lineup_id": 8,
|
||||
"current_pitcher_lineup_id": 10,
|
||||
"current_catcher_lineup_id": 11,
|
||||
"current_batter": {"lineup_id": 8, "card_id": 125, "position": "RF", "batting_order": 4},
|
||||
"current_pitcher": {"lineup_id": 10, "card_id": 130, "position": "P", "batting_order": 9},
|
||||
"current_catcher": {"lineup_id": 11, "card_id": 131, "position": "C", "batting_order": 2},
|
||||
"current_on_base_code": 2,
|
||||
"pending_decision": None,
|
||||
"decisions_this_play": {},
|
||||
|
||||
@ -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
|
||||
# ============================================================================
|
||||
@ -276,7 +316,7 @@ class TestOffensiveDecision:
|
||||
class TestGameState:
|
||||
"""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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -284,8 +324,8 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1
|
||||
)
|
||||
current_batter=sample_batter
|
||||
)
|
||||
assert state.game_id == game_id
|
||||
assert state.league_id == "sba"
|
||||
assert state.home_team_id == 1
|
||||
@ -297,7 +337,7 @@ class TestGameState:
|
||||
assert state.home_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"""
|
||||
game_id = uuid4()
|
||||
for league in ['sba', 'pd']:
|
||||
@ -306,11 +346,11 @@ class TestGameState:
|
||||
league_id=league,
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1
|
||||
current_batter=sample_batter
|
||||
)
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
with pytest.raises(ValidationError):
|
||||
@ -319,10 +359,10 @@ class TestGameState:
|
||||
league_id="invalid",
|
||||
home_team_id=1,
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
for status in ['pending', 'active', 'paused', 'completed']:
|
||||
@ -331,12 +371,12 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
with pytest.raises(ValidationError):
|
||||
@ -345,11 +385,11 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
status="invalid"
|
||||
)
|
||||
|
||||
def test_game_state_valid_halves(self):
|
||||
def test_game_state_valid_halves(self, sample_batter):
|
||||
"""Test valid inning halves"""
|
||||
game_id = uuid4()
|
||||
for half in ['top', 'bottom']:
|
||||
@ -358,12 +398,12 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
with pytest.raises(ValidationError):
|
||||
@ -372,11 +412,11 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
half="middle"
|
||||
)
|
||||
|
||||
def test_game_state_outs_validation(self):
|
||||
def test_game_state_outs_validation(self, sample_batter):
|
||||
"""Test outs validation (0-2)"""
|
||||
game_id = uuid4()
|
||||
|
||||
@ -387,7 +427,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
outs=outs
|
||||
)
|
||||
assert state.outs == outs
|
||||
@ -399,11 +439,11 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
outs=3
|
||||
)
|
||||
|
||||
def test_get_batting_team_id(self):
|
||||
def test_get_batting_team_id(self, sample_batter):
|
||||
"""Test getting batting team ID"""
|
||||
game_id = uuid4()
|
||||
|
||||
@ -413,7 +453,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
half="top"
|
||||
)
|
||||
assert state.get_batting_team_id() == 2
|
||||
@ -422,7 +462,7 @@ class TestGameState:
|
||||
state.half = "bottom"
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
|
||||
@ -432,7 +472,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
half="top"
|
||||
)
|
||||
assert state.get_fielding_team_id() == 1
|
||||
@ -441,7 +481,7 @@ class TestGameState:
|
||||
state.half = "bottom"
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -449,7 +489,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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_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_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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -466,7 +506,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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_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)
|
||||
assert runner is None
|
||||
|
||||
def test_bases_occupied(self):
|
||||
def test_bases_occupied(self, sample_batter):
|
||||
"""Test getting list of occupied bases"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -486,7 +526,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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_third=LineupPlayerState(lineup_id=3, card_id=103, position="SS", batting_order=3)
|
||||
)
|
||||
@ -494,7 +534,7 @@ class TestGameState:
|
||||
bases = state.bases_occupied()
|
||||
assert bases == [1, 3]
|
||||
|
||||
def test_clear_bases(self):
|
||||
def test_clear_bases(self, sample_batter):
|
||||
"""Test clearing all runners"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -502,7 +542,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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_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_third is None
|
||||
|
||||
def test_add_runner(self):
|
||||
def test_add_runner(self, sample_batter):
|
||||
"""Test adding a runner to a base"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -522,7 +562,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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)
|
||||
@ -530,7 +570,7 @@ class TestGameState:
|
||||
assert len(state.get_all_runners()) == 1
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -538,7 +578,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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)
|
||||
)
|
||||
|
||||
@ -548,7 +588,7 @@ class TestGameState:
|
||||
runner = state.get_runner_at_base(1)
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -556,7 +596,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
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)
|
||||
)
|
||||
|
||||
@ -564,7 +604,7 @@ class TestGameState:
|
||||
assert state.is_runner_on_first() is False
|
||||
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)"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -572,7 +612,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
half="top",
|
||||
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 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"""
|
||||
game_id = uuid4()
|
||||
|
||||
@ -592,7 +632,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
half="top",
|
||||
on_third=LineupPlayerState(lineup_id=1, card_id=101, position="CF", batting_order=1)
|
||||
)
|
||||
@ -606,7 +646,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
half="bottom",
|
||||
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.away_score == 0
|
||||
|
||||
def test_increment_outs(self):
|
||||
def test_increment_outs(self, sample_batter):
|
||||
"""Test incrementing outs"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -622,7 +662,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
outs=0
|
||||
)
|
||||
|
||||
@ -635,7 +675,7 @@ class TestGameState:
|
||||
assert state.increment_outs() is True # 3 outs - end of inning
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -643,7 +683,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=3,
|
||||
half="top",
|
||||
outs=2,
|
||||
@ -656,7 +696,7 @@ class TestGameState:
|
||||
assert state.outs == 0 # Outs reset
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -664,7 +704,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=3,
|
||||
half="bottom",
|
||||
outs=2,
|
||||
@ -677,7 +717,7 @@ class TestGameState:
|
||||
assert state.outs == 0 # Outs reset
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -685,7 +725,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=5,
|
||||
half="bottom",
|
||||
home_score=5,
|
||||
@ -694,7 +734,7 @@ class TestGameState:
|
||||
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -702,7 +742,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=9,
|
||||
half="bottom",
|
||||
home_score=5,
|
||||
@ -711,7 +751,7 @@ class TestGameState:
|
||||
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -719,7 +759,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=9,
|
||||
half="bottom",
|
||||
home_score=2,
|
||||
@ -728,7 +768,7 @@ class TestGameState:
|
||||
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
state = GameState(
|
||||
@ -736,7 +776,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=10,
|
||||
half="bottom",
|
||||
home_score=5,
|
||||
@ -745,7 +785,7 @@ class TestGameState:
|
||||
|
||||
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"""
|
||||
game_id = uuid4()
|
||||
# Bottom of 9th, away team ahead - home team can't catch up
|
||||
@ -756,7 +796,7 @@ class TestGameState:
|
||||
league_id="sba",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
current_batter_lineup_id=1,
|
||||
current_batter=sample_batter,
|
||||
inning=9,
|
||||
half="bottom",
|
||||
outs=0,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user