CLAUDE: Fix game recovery for new GameState structure

Fixed state_manager._rebuild_state_from_data to provide required
current_batter field when recovering games from database. The GameState
model now requires current_batter as a LineupPlayerState object, but
recovery was not populating this field, causing validation errors.

Changes:
- state_manager.py: Create placeholder current_batter during recovery
  - Build LineupPlayerState from first active batter (batting_order=1)
  - Fallback to first available lineup if no #1 batter found
  - Raise error if no lineups exist (invalid game state)
  - _prepare_next_play() will correct the batter after recovery
  - Moved get_lineup_player helper to top of method (removed duplicate)

- tests/unit/core/test_state_manager.py: Update test to use new structure
  - test_update_state_nonexistent_raises_error: Create LineupPlayerState
    instead of using old current_batter_lineup_id field

All 26 state_manager unit tests passing. Game recovery now works
correctly in terminal client - fixes "current_batter Field required"
validation error when running status command on recovered games.

🤖 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-04 15:26:00 -06:00
parent 440adf2c26
commit e6bd66ee39
3 changed files with 122 additions and 90 deletions

View File

@ -254,32 +254,9 @@ class StateManager:
Reconstructed GameState
"""
game = game_data['game']
state = GameState(
game_id=game['id'],
league_id=game['league_id'],
home_team_id=game['home_team_id'],
away_team_id=game['away_team_id'],
home_team_is_ai=game.get('home_team_is_ai', False),
away_team_is_ai=game.get('away_team_is_ai', False),
status=game['status'],
inning=game.get('current_inning', 1),
half=game.get('current_half', 'top'),
home_score=game.get('home_score', 0),
away_score=game.get('away_score', 0),
play_count=len(game_data.get('plays', []))
)
# Get last completed play to recover runner state and batter indices
plays = game_data.get('plays', [])
if plays:
# Sort by play_number desc and get last completed play
completed_plays = [p for p in plays if p.get('complete', False)]
if completed_plays:
last_play = max(completed_plays, key=lambda p: p['play_number'])
lineups = game_data.get('lineups', [])
# Build lineup lookup dict for quick access
lineups = game_data.get('lineups', [])
lineup_dict = {l['id']: l for l in lineups}
# Helper function to create LineupPlayerState from lineup_id
@ -295,6 +272,53 @@ class StateManager:
is_active=lineup.get('is_active', True)
)
# Get placeholder current_batter (required field)
# _prepare_next_play() will set the correct batter after recovery
current_batter_placeholder = None
for lineup in lineups:
if lineup.get('batting_order') == 1 and lineup.get('is_active'):
current_batter_placeholder = get_lineup_player(lineup['id'])
break
# If no batter found, use first available lineup
if not current_batter_placeholder and lineups:
first_lineup = lineups[0]
current_batter_placeholder = LineupPlayerState(
lineup_id=first_lineup['id'],
card_id=first_lineup.get('card_id') or 0,
position=first_lineup['position'],
batting_order=first_lineup.get('batting_order'),
is_active=True
)
# If still no batter (no lineups at all), raise error - game is in invalid state
if not current_batter_placeholder:
raise ValueError(f"Cannot recover game {game['id']}: No lineups found")
state = GameState(
game_id=game['id'],
league_id=game['league_id'],
home_team_id=game['home_team_id'],
away_team_id=game['away_team_id'],
home_team_is_ai=game.get('home_team_is_ai', False),
away_team_is_ai=game.get('away_team_is_ai', False),
status=game['status'],
inning=game.get('current_inning', 1),
half=game.get('current_half', 'top'),
home_score=game.get('home_score', 0),
away_score=game.get('away_score', 0),
play_count=len(game_data.get('plays', [])),
current_batter=current_batter_placeholder # Placeholder - corrected by _prepare_next_play()
)
# Get last completed play to recover runner state and batter indices
plays = game_data.get('plays', [])
if plays:
# Sort by play_number desc and get last completed play
completed_plays = [p for p in plays if p.get('complete', False)]
if completed_plays:
last_play = max(completed_plays, key=lambda p: p['play_number'])
# Recover runners from *_final fields (where they ended up after last play)
# Check each base - if a runner ended on that base, place them there
runner_count = 0

View File

@ -196,13 +196,21 @@ class TestStateManagerGetUpdate:
def test_update_state_nonexistent_raises_error(self, state_manager):
"""Test updating nonexistent game raises error"""
from app.models.game_models import LineupPlayerState
fake_id = uuid4()
fake_batter = LineupPlayerState(
lineup_id=1,
card_id=100,
position='CF',
batting_order=1
)
fake_state = GameState(
game_id=fake_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
current_batter_lineup_id=1
current_batter=fake_batter
)
with pytest.raises(ValueError, match="not found"):

142
backend/uv.lock generated
View File

@ -626,77 +626,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "paper-dynasty-backend"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "click" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "httpx" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pendulum" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "python-socketio" },
{ name = "redis" },
{ name = "rich" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "flake8" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "alembic", specifier = "==1.14.0" },
{ name = "asyncpg", specifier = "==0.30.0" },
{ name = "click", specifier = "==8.1.8" },
{ name = "fastapi", specifier = "==0.115.6" },
{ name = "greenlet", specifier = "==3.2.4" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "passlib", extras = ["bcrypt"], specifier = "==1.7.4" },
{ name = "pendulum", specifier = "==3.0.0" },
{ name = "psycopg2-binary", specifier = "==2.9.10" },
{ name = "pydantic", specifier = "==2.10.6" },
{ name = "pydantic-settings", specifier = "==2.7.1" },
{ name = "python-dotenv", specifier = "==1.0.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = "==3.3.0" },
{ name = "python-multipart", specifier = "==0.0.20" },
{ name = "python-socketio", specifier = "==5.11.4" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "rich", specifier = "==13.9.4" },
{ name = "sqlalchemy", specifier = "==2.0.36" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.34.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = "==24.10.0" },
{ name = "flake8", specifier = "==7.1.1" },
{ name = "mypy", specifier = "==1.14.1" },
{ name = "pytest", specifier = "==8.3.4" },
{ name = "pytest-asyncio", specifier = "==0.25.2" },
{ name = "pytest-cov", specifier = "==6.0.0" },
]
[[package]]
name = "passlib"
version = "1.7.4"
@ -1111,6 +1040,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" },
]
[[package]]
name = "strat-gameplay-backend"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "alembic" },
{ name = "asyncpg" },
{ name = "click" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "httpx" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pendulum" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "python-socketio" },
{ name = "redis" },
{ name = "rich" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "flake8" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "alembic", specifier = "==1.14.0" },
{ name = "asyncpg", specifier = "==0.30.0" },
{ name = "click", specifier = "==8.1.8" },
{ name = "fastapi", specifier = "==0.115.6" },
{ name = "greenlet", specifier = "==3.2.4" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "passlib", extras = ["bcrypt"], specifier = "==1.7.4" },
{ name = "pendulum", specifier = "==3.0.0" },
{ name = "psycopg2-binary", specifier = "==2.9.10" },
{ name = "pydantic", specifier = "==2.10.6" },
{ name = "pydantic-settings", specifier = "==2.7.1" },
{ name = "python-dotenv", specifier = "==1.0.1" },
{ name = "python-jose", extras = ["cryptography"], specifier = "==3.3.0" },
{ name = "python-multipart", specifier = "==0.0.20" },
{ name = "python-socketio", specifier = "==5.11.4" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "rich", specifier = "==13.9.4" },
{ name = "sqlalchemy", specifier = "==2.0.36" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.34.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = "==24.10.0" },
{ name = "flake8", specifier = "==7.1.1" },
{ name = "mypy", specifier = "==1.14.1" },
{ name = "pytest", specifier = "==8.3.4" },
{ name = "pytest-asyncio", specifier = "==0.25.2" },
{ name = "pytest-cov", specifier = "==6.0.0" },
]
[[package]]
name = "time-machine"
version = "2.19.0"