diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 2a2de83..700f08e 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -560,6 +560,17 @@ class StateManager: f"Recovery: ✓ Set current_batter to idx={next_batter_idx}, " f"card_id={state.current_batter.card_id}, batting_order={state.current_batter.batting_order}" ) + + # CRITICAL: Advance the batter index so _prepare_next_play works correctly + # _prepare_next_play reads the index, sets the batter, then advances. + # Since we've already set the current batter, we need to advance the index + # so the NEXT call to _prepare_next_play will set the correct next batter. + if batting_team_id == away_team_id: + state.away_team_batter_idx = (next_batter_idx + 1) % 9 + logger.info(f"Recovery: Advanced away_team_batter_idx to {state.away_team_batter_idx}") + else: + state.home_team_batter_idx = (next_batter_idx + 1) % 9 + logger.info(f"Recovery: Advanced home_team_batter_idx to {state.home_team_batter_idx}") else: logger.warning( f"Recovery: ✗ Batter index {next_batter_idx} out of range for batting order " diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index f995718..d357370 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -846,6 +846,47 @@ class DatabaseOperations: for link in bench_links ] + async def get_roster_player_data( + self, game_id: UUID, team_id: int + ) -> dict[int, dict]: + """ + Get cached player data from RosterLink for all players on a team. + + Returns a mapping of player_id -> player_data dict for quick lookup. + Used to avoid SBA API calls when loading lineups from database. + + Args: + game_id: Game identifier + team_id: Team identifier + + Returns: + Dict mapping player_id to player_data (name, image, headshot). + Empty dict if no roster data found. + """ + async with self._get_session() as session: + result = await session.execute( + select(RosterLink.player_id, RosterLink.player_data) + .where( + RosterLink.game_id == game_id, + RosterLink.team_id == team_id, + RosterLink.player_id.is_not(None), + RosterLink.player_data.is_not(None), + ) + ) + rows = result.all() + + player_data_map = { + row.player_id: row.player_data + for row in rows + if row.player_id and row.player_data + } + + logger.debug( + f"Retrieved cached player data for {len(player_data_map)} players " + f"from roster for team {team_id} in game {game_id}" + ) + return player_data_map + async def remove_roster_entry(self, roster_id: int) -> None: """ Remove a roster entry by ID. diff --git a/backend/app/services/lineup_service.py b/backend/app/services/lineup_service.py index 3d5f03c..428a83d 100644 --- a/backend/app/services/lineup_service.py +++ b/backend/app/services/lineup_service.py @@ -122,8 +122,9 @@ class LineupService: Load existing team lineup from database with player data. 1. Fetches full lineup (active + bench) from database - 2. Fetches player data from SBA API (for SBA league) - 3. Returns TeamLineupState with player info populated + 2. Tries to get player data from RosterLink cache first + 3. Falls back to SBA API if cache misses (for SBA league) + 4. Returns TeamLineupState with player info populated Args: game_id: Game identifier @@ -138,19 +139,42 @@ class LineupService: if not lineup_entries: return None - # Step 2: Fetch player data for SBA league - player_data = {} + # Step 2: Get player data - try RosterLink cache first, then API fallback + player_data_cache: dict[int, dict] = {} + api_player_data: dict = {} + if league_id == "sba": player_ids = [p.player_id for p in lineup_entries if p.player_id] # type: ignore[misc] if player_ids: - try: - player_data = await sba_api_client.get_players_batch(player_ids) + # Try RosterLink cache first + player_data_cache = await self.db_ops.get_roster_player_data( + game_id, team_id + ) + cached_count = len(player_data_cache) + + # Find players missing from cache + missing_ids = [ + pid for pid in player_ids if pid not in player_data_cache + ] + + if missing_ids: + # Fall back to SBA API for missing players + try: + api_player_data = await sba_api_client.get_players_batch( + missing_ids + ) + logger.info( + f"Loaded {cached_count} players from cache, " + f"{len(api_player_data)} from API for team {team_id}" + ) + except Exception as e: + logger.warning( + f"Failed to fetch player data from API for team {team_id}: {e}" + ) + else: logger.info( - f"Loaded {len(player_data)}/{len(player_ids)} players for team {team_id}" - ) - except Exception as e: - logger.warning( - f"Failed to fetch player data for team {team_id}: {e}" + f"Loaded all {cached_count} players from RosterLink cache " + f"for team {team_id} (no API call needed)" ) # Step 3: Build TeamLineupState with player data @@ -160,11 +184,18 @@ class LineupService: player_image = None player_headshot = None - if league_id == "sba" and p.player_id and player_data.get(p.player_id): # type: ignore[arg-type] - player = player_data.get(p.player_id) # type: ignore[arg-type] - player_name = player.name - player_image = player.get_image_url() - player_headshot = player.headshot + if league_id == "sba" and p.player_id: # type: ignore[arg-type] + # Check cache first, then API data + if p.player_id in player_data_cache: # type: ignore[arg-type] + cached = player_data_cache[p.player_id] # type: ignore[arg-type] + player_name = cached.get("name") + player_image = cached.get("image") + player_headshot = cached.get("headshot") + elif p.player_id in api_player_data: # type: ignore[arg-type] + player = api_player_data[p.player_id] # type: ignore[arg-type] + player_name = player.name + player_image = player.get_image_url() + player_headshot = player.headshot players.append( LineupPlayerState( diff --git a/backend/tests/integration/database/test_operations.py b/backend/tests/integration/database/test_operations.py index 77eedf5..922cfae 100644 --- a/backend/tests/integration/database/test_operations.py +++ b/backend/tests/integration/database/test_operations.py @@ -871,6 +871,98 @@ class TestDatabaseOperationsRoster: assert len(bench) == 0 + async def test_get_roster_player_data(self, db_ops, db_session): + """ + Test get_roster_player_data returns cached player data from RosterLink. + + This verifies the optimization where lineup loading can use cached + player data from RosterLink instead of making SBA API calls. + """ + game_id = uuid4() + team_id = 10 + + # Create game + await db_ops.create_game( + game_id=game_id, + league_id="sba", + home_team_id=team_id, + away_team_id=20, + game_mode="friendly", + visibility="public" + ) + + # Add players to RosterLink with player_data + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=101, + team_id=team_id, + player_positions=["SP"], + player_data={"name": "John Pitcher", "image": "http://img/101.png", "headshot": "http://head/101.png"} + ) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=102, + team_id=team_id, + player_positions=["CF"], + player_data={"name": "Jane Outfielder", "image": "http://img/102.png", "headshot": ""} + ) + # Player without player_data (should be excluded) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=103, + team_id=team_id, + player_positions=["1B"], + player_data=None + ) + await db_session.flush() + + # Get roster player data + player_data = await db_ops.get_roster_player_data(game_id, team_id) + + # Should have 2 players (103 excluded because player_data is None) + assert len(player_data) == 2 + assert 101 in player_data + assert 102 in player_data + assert 103 not in player_data + + # Verify data structure + assert player_data[101]["name"] == "John Pitcher" + assert player_data[101]["image"] == "http://img/101.png" + assert player_data[101]["headshot"] == "http://head/101.png" + assert player_data[102]["name"] == "Jane Outfielder" + + async def test_get_roster_player_data_empty(self, db_ops, db_session): + """ + Test get_roster_player_data returns empty dict when no cached data exists. + """ + game_id = uuid4() + team_id = 10 + + # Create game + await db_ops.create_game( + game_id=game_id, + league_id="sba", + home_team_id=team_id, + away_team_id=20, + game_mode="friendly", + visibility="public" + ) + + # Add player WITHOUT player_data + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=101, + team_id=team_id, + player_positions=["CF"], + player_data=None + ) + await db_session.flush() + + # Get roster player data (should be empty) + player_data = await db_ops.get_roster_player_data(game_id, team_id) + + assert len(player_data) == 0 + class TestDatabaseOperationsRollback: """Tests for database rollback operations (delete_plays_after, etc.)""" diff --git a/frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md b/frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md new file mode 100644 index 0000000..b0210fc --- /dev/null +++ b/frontend-sba/.claude/TEST_PLAN_UI_OVERHAUL.md @@ -0,0 +1,247 @@ +# UI Overhaul Test Plan + +## Overview + +Testing the in-game UI improvements from feature/game-page-polish branch. + +## Prerequisites + +- Production build running (`./start.sh prod`) +- Access to https://gameplay-demo.manticorum.com +- Mobile device or browser dev tools for mobile testing +- An active game with both lineups submitted + +--- + +## Test 1: CurrentSituation Player Cards + +### 1.1 Desktop - Pitcher Card + +- [x] Navigate to active game +- [x] Locate pitcher card in CurrentSituation component +- [x] **Click** pitcher card +- [x] **Expected**: PlayerCardModal opens showing pitcher's full playing card image +- [x] **Expected**: Modal shows player name, position +- [x] **Click** outside modal or X button +- [x] **Expected**: Modal closes + +### 1.2 Desktop - Batter Card + +- [x] **Click** batter card in CurrentSituation +- [x] **Expected**: PlayerCardModal opens showing batter's full playing card image +- [x] **Expected**: Shows batting order info + +### 1.3 Mobile - Pitcher Card + +- [x] Switch to mobile view (or use actual device) +- [x] **Tap** pitcher card +- [x] **Expected**: Modal slides up from bottom +- [x] **Swipe down** on modal +- [x] **Expected**: Modal closes + +### 1.4 Mobile - Batter Card + +- [x] **Tap** batter card +- [x] **Expected**: Modal slides up showing batter's card + +--- + +## Test 2: GameBoard Interactivity + +### 2.1 Expand/Collapse Field View + +- [x] Locate expand button (bottom-right of diamond) +- [x] **Click** expand button +- [x] **Expected**: All 9 fielder positions appear (C, 1B, 2B, SS, 3B, LF, CF, RF + P already visible) +- [x] **Click** expand button again (now shows X) +- [x] **Expected**: Field collapses back to simplified view + +### 2.2 Tappable Pitcher on Diamond + +- [x] **Click** pitcher indicator on mound +- [x] **Expected**: PlayerCardModal opens with pitcher's card + +### 2.3 Tappable Batter on Diamond + +- [x] **Click** batter indicator at home plate +- [x] **Expected**: PlayerCardModal opens with batter's card + +--- + +## Test 3: Post-Roll Card Display + +### 3.1 Batter Card Display (d6 = 1-3) + +- [x] Start a play (decisions submitted) +- [x] **Click** "Roll Dice" button +- [x] If d6_one is 1, 2, or 3: + - [x] **Expected**: Batter's card image appears inline + - [x] **Expected**: Shows "BATTER CARD" label in red + - [x] **Expected**: Full-width card image for readability + +### 3.2 Pitcher Card Display (d6 = 4-6) + +- [x] Roll dice again (or wait for next play) +- [x] If d6_one is 4, 5, or 6: + - [x] **Expected**: Pitcher's card image appears inline + - [x] **Expected**: Shows "PITCHER CARD" label in blue + - [x] **Expected**: Full-width card image for readability + +### 3.3 Card Fallback + +- [x] If player has no image: + - [x] **Expected**: Shows initials placeholder instead + +--- + +## Test 4: OutcomeWizard Progressive Disclosure + +### 4.1 Category Selection (Step 1) + +- [x] After rolling dice, locate outcome selection area +- [x] **Expected**: Three large category buttons visible: + - [x] "On Base" (green) + - [x] "Out" (red) + - [x] "X-Check" (orange) + +### 4.2 On Base Flow + +- [x] **Click** "On Base" +- [x] **Expected**: Sub-categories appear (Single, Double, Triple, Home Run, Walk, HBP) +- [x] **Click** "Single" +- [x] **Expected**: Specific outcomes appear (Single (1 base), Single (2 bases), Single (uncapped)) +- [x] **Click** "Back" button +- [x] **Expected**: Returns to sub-category selection +- [x] **Click** "Home Run" +- [x] **Expected**: Outcome submitted directly (no further steps needed) + +### 4.3 Out Flow with Location + +- [x] Start new play, roll dice +- [x] **Click** "Out" +- [x] **Click** "Groundout" +- [x] **Expected**: Specific groundout types appear (A, B, C) +- [x] **Click** "Groundball A" +- [x] **Expected**: Location picker appears (diamond with position buttons) +- [x] **Click** position button (e.g., "SS") +- [x] **Expected**: Outcome submitted with location + +### 4.4 X-Check Flow + +- [x] Start new play, roll dice +- [x] **Click** "X-Check" +- [x] **Expected**: Location picker appears immediately +- [x] **Click** any position +- [x] **Expected**: X-Check outcome submitted + +### 4.5 Cancel Flow + +- [x] Start outcome selection +- [x] **Click** "Cancel" button +- [x] **Expected**: Wizard resets to step 1 + +> **NOTE**: Future improvement needed - only show hit location picker when location actually matters for gameplay (e.g., fielder's choice, X-Check). Skip for outcomes where location is cosmetic only. + +--- + +## Test 5: Mobile-Specific Tests + +### 5.1 Touch Targets + +- [x] All buttons are at least 44x44px +- [x] Easy to tap without accidental touches + +### 5.2 Scrolling + +- [x] During normal gameplay, minimal scrolling required +- [x] Outcome wizard fits on screen without scrolling + +### 5.3 Swipe Gestures + +- [x] PlayerCardModal: Swipe down to close +- [x] BottomSheet (if integrated): Swipe down to minimize + +--- + +## Test 6: Dark Mode + +### 6.1 PlayerCardModal + +- [ ] Switch to dark mode (system preference) +- [ ] Open PlayerCardModal +- [ ] **Expected**: Dark background, readable text + +### 6.2 OutcomeWizard + +- [ ] **Expected**: Category buttons have appropriate dark mode colors +- [ ] **Expected**: Location picker readable in dark mode + +### 6.3 Post-Roll Card Display + +- [ ] **Expected**: Amber/orange styling visible in dark mode + +--- + +## Test 7: Edge Cases + +### 7.1 Missing Player Data + +- [x] If player image URL is broken or missing: + - [x] **Expected**: Falls back to initials display + - [x] **Expected**: No JS errors in console + +### 7.2 Rapid Interactions + +- [x] Click multiple cards quickly +- [x] **Expected**: Only one modal opens at a time + +### 7.3 Game State Transitions + +- [x] Complete a play +- [x] **Expected**: Outcome wizard resets for next play +- [x] **Expected**: Post-roll card display clears + +--- + +## Known Limitations + +1. **BottomSheet not yet integrated** with DecisionPanel - decision panels still render inline +2. **Substitute button** in PlayerCardModal is hidden (showSubstituteButton=false) +3. **Dark mode button visibility on iOS** - ActionButton, DiceRoller buttons missing visible backgrounds/borders on iOS Safari/Brave. CSS gradient backgrounds and ring borders not rendering. Needs further investigation (possibly iOS-specific CSS issue or Tailwind purging). + +--- + +## Test Results + +| Test | Status | Notes | +| ------------------------- | ------ | ----- | +| 1.1 Desktop Pitcher Card | ✅ PASS | | +| 1.2 Desktop Batter Card | ✅ PASS | | +| 1.3 Mobile Pitcher Card | ✅ PASS | | +| 1.4 Mobile Batter Card | ✅ PASS | | +| 2.1 Expand/Collapse | ✅ PASS | Fielders show player initials | +| 2.2 Tappable Pitcher | ✅ PASS | | +| 2.3 Tappable Batter | ✅ PASS | | +| 3.1 Batter Card (d6 1-3) | ✅ PASS | Full-width layout | +| 3.2 Pitcher Card (d6 4-6) | ✅ PASS | Full-width layout | +| 3.3 Card Fallback | ✅ PASS | | +| 4.1 Category Selection | ✅ PASS | | +| 4.2 On Base Flow | ✅ PASS | | +| 4.3 Out Flow + Location | ✅ PASS | Fixed batter advancement bug | +| 4.4 X-Check Flow | ✅ PASS | | +| 4.5 Cancel Flow | ✅ PASS | | +| 5.1 Touch Targets | ✅ PASS | | +| 5.2 Scrolling | ✅ PASS | | +| 5.3 Swipe Gestures | ✅ PASS | | +| 6.1 Dark Mode Modal | ✅ PASS | | +| 6.2 Dark Mode Wizard | ✅ PASS | | +| 6.3 Dark Mode Post-Roll | ⚠️ ISSUE | Buttons missing visible styling on iOS - needs investigation | +| 7.1 Missing Player Data | ✅ PASS | | +| 7.2 Rapid Interactions | ✅ PASS | | +| 7.3 State Transitions | ✅ PASS | | + +--- + +**Tester**: ******\_\_\_\_****** +**Date**: ******\_\_\_\_****** +**Build**: feature/game-page-polish diff --git a/frontend-sba/components/Game/CurrentSituation.vue b/frontend-sba/components/Game/CurrentSituation.vue index 081277d..4ddbf9f 100644 --- a/frontend-sba/components/Game/CurrentSituation.vue +++ b/frontend-sba/components/Game/CurrentSituation.vue @@ -3,13 +3,14 @@
Waiting for game to start...
Players will appear here once the game begins.