From e6bd66ee393eee9ac93919a76e56d81d8d301d60 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 4 Nov 2025 15:26:00 -0600 Subject: [PATCH] CLAUDE: Fix game recovery for new GameState structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/core/state_manager.py | 60 +++++--- backend/tests/unit/core/test_state_manager.py | 10 +- backend/uv.lock | 142 +++++++++--------- 3 files changed, 122 insertions(+), 90 deletions(-) diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 0c9e723..c840f41 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -254,6 +254,46 @@ class StateManager: Reconstructed GameState """ game = game_data['game'] + lineups = game_data.get('lineups', []) + + # Build lineup lookup dict for quick access + lineup_dict = {l['id']: l for l in lineups} + + # Helper function to create LineupPlayerState from lineup_id + def get_lineup_player(lineup_id: int) -> Optional[LineupPlayerState]: + if not lineup_id or lineup_id not in lineup_dict: + return None + lineup = lineup_dict[lineup_id] + return LineupPlayerState( + lineup_id=lineup['id'], + card_id=lineup['card_id'] or 0, # Handle nullable + position=lineup['position'], + batting_order=lineup.get('batting_order'), + 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'], @@ -267,7 +307,8 @@ class StateManager: 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', [])) + 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 @@ -278,23 +319,6 @@ class StateManager: if completed_plays: last_play = max(completed_plays, key=lambda p: p['play_number']) - # 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 - def get_lineup_player(lineup_id: int) -> Optional[LineupPlayerState]: - if not lineup_id or lineup_id not in lineup_dict: - return None - lineup = lineup_dict[lineup_id] - return LineupPlayerState( - lineup_id=lineup['id'], - card_id=lineup['card_id'] or 0, # Handle nullable - position=lineup['position'], - batting_order=lineup.get('batting_order'), - is_active=lineup.get('is_active', True) - ) - # 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 diff --git a/backend/tests/unit/core/test_state_manager.py b/backend/tests/unit/core/test_state_manager.py index 6ce85dc..05c5582 100644 --- a/backend/tests/unit/core/test_state_manager.py +++ b/backend/tests/unit/core/test_state_manager.py @@ -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"): diff --git a/backend/uv.lock b/backend/uv.lock index 296bfa9..e5e8414 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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"