CLAUDE: Fix pitcher/catcher recovery and lineup data format

Backend fixes:
- state_manager: Properly recover current_pitcher and current_catcher from
  fielding team during game state recovery (fixes pitcher badge not showing)
- handlers: Add headshot field to lineup data, use lineup_service for proper
  player data loading on cache miss
- lineup_service: Minor adjustments for headshot support

Frontend fixes:
- player.ts: Update Lineup type to match WebSocket event format
  - lineup_id (was 'id'), card_id fields
  - player.headshot for UI circles
  - Optional fields for event variations
- CurrentSituation.vue: Adapt to updated type structure
- Substitution selectors: Use updated Lineup type fields

This fixes the issue where pitcher badge wouldn't show after game recovery
because current_pitcher was being set from batting team instead of fielding team.

🤖 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-19 16:30:05 -06:00
parent 9546d2a370
commit 1a562a75d2
11 changed files with 118 additions and 67 deletions

View File

@ -272,43 +272,70 @@ class StateManager:
is_active=lineup.get('is_active', True) is_active=lineup.get('is_active', True)
) )
# Get placeholder current_batter (required field) # Determine fielding team based on current half
# _prepare_next_play() will set the correct batter after recovery current_half = game.get('current_half', 'top')
home_team_id = game['home_team_id']
away_team_id = game['away_team_id']
if current_half == 'top':
# Top of inning: away team batting, home team fielding
batting_team_id = away_team_id
fielding_team_id = home_team_id
else:
# Bottom of inning: home team batting, away team fielding
batting_team_id = home_team_id
fielding_team_id = away_team_id
# Get current batter from batting team (player with batting_order 1 as placeholder)
current_batter_placeholder = None current_batter_placeholder = None
for lineup in lineups: for lineup in lineups:
if lineup.get('batting_order') == 1 and lineup.get('is_active'): if (lineup.get('team_id') == batting_team_id and
lineup.get('batting_order') == 1 and
lineup.get('is_active')):
current_batter_placeholder = get_lineup_player(lineup['id']) current_batter_placeholder = get_lineup_player(lineup['id'])
break break
# If no batter found, use first available lineup # If no batter found, use first available lineup from batting team
if not current_batter_placeholder and lineups: if not current_batter_placeholder:
first_lineup = lineups[0] for lineup in lineups:
current_batter_placeholder = LineupPlayerState( if lineup.get('team_id') == batting_team_id and lineup.get('is_active'):
lineup_id=first_lineup['id'], current_batter_placeholder = get_lineup_player(lineup['id'])
card_id=first_lineup.get('card_id') or 0, break
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 still no batter (no lineups at all), raise error - game is in invalid state
if not current_batter_placeholder: if not current_batter_placeholder:
raise ValueError(f"Cannot recover game {game['id']}: No lineups found") raise ValueError(f"Cannot recover game {game['id']}: No lineups found for batting team")
# Get current pitcher and catcher from fielding team
current_pitcher = None
current_catcher = None
for lineup in lineups:
if lineup.get('team_id') == fielding_team_id and lineup.get('is_active'):
if lineup.get('position') == 'P' and not current_pitcher:
current_pitcher = get_lineup_player(lineup['id'])
elif lineup.get('position') == 'C' and not current_catcher:
current_catcher = get_lineup_player(lineup['id'])
# Stop if we found both
if current_pitcher and current_catcher:
break
state = GameState( state = GameState(
game_id=game['id'], game_id=game['id'],
league_id=game['league_id'], league_id=game['league_id'],
home_team_id=game['home_team_id'], home_team_id=home_team_id,
away_team_id=game['away_team_id'], away_team_id=away_team_id,
home_team_is_ai=game.get('home_team_is_ai', False), home_team_is_ai=game.get('home_team_is_ai', False),
away_team_is_ai=game.get('away_team_is_ai', False), away_team_is_ai=game.get('away_team_is_ai', False),
status=game['status'], status=game['status'],
inning=game.get('current_inning', 1), inning=game.get('current_inning', 1),
half=game.get('current_half', 'top'), half=current_half,
home_score=game.get('home_score', 0), home_score=game.get('home_score', 0),
away_score=game.get('away_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() current_batter=current_batter_placeholder,
current_pitcher=current_pitcher,
current_catcher=current_catcher
) )
# Get last completed play to recover runner state and batter indices # Get last completed play to recover runner state and batter indices

View File

@ -46,7 +46,8 @@ class LineupPlayerState(BaseModel):
# Player data (loaded at game start from SBA/PD API) # Player data (loaded at game start from SBA/PD API)
player_name: Optional[str] = None player_name: Optional[str] = None
player_image: Optional[str] = None player_image: Optional[str] = None # Card image
player_headshot: Optional[str] = None # Headshot for UI circles
# Phase 3E-Main: Position rating (loaded at game start for PD league) # Phase 3E-Main: Position rating (loaded at game start for PD league)
position_rating: Optional['PositionRating'] = None position_rating: Optional['PositionRating'] = None

View File

@ -30,7 +30,8 @@ class LineupEntryWithPlayer:
is_active: bool is_active: bool
# Player data from API # Player data from API
player_name: str player_name: str
player_image: str player_image: str # Card image
player_headshot: str # Headshot for UI circles
class LineupService: class LineupService:
@ -88,11 +89,13 @@ class LineupService:
# Step 2: Fetch player data from SBA API # Step 2: Fetch player data from SBA API
player_name = f"Player #{player_id}" player_name = f"Player #{player_id}"
player_image = "" player_image = ""
player_headshot = ""
try: try:
player = await sba_api_client.get_player(player_id) player = await sba_api_client.get_player(player_id)
player_name = player.name player_name = player.name
player_image = player.get_image_url() player_image = player.get_image_url()
player_headshot = player.headshot or ""
logger.info(f"Loaded player data for {player_id}: {player_name}") logger.info(f"Loaded player data for {player_id}: {player_name}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to fetch player data for {player_id}: {e}") logger.warning(f"Failed to fetch player data for {player_id}: {e}")
@ -107,7 +110,8 @@ class LineupService:
is_starter=is_starter, is_starter=is_starter,
is_active=True, is_active=True,
player_name=player_name, player_name=player_name,
player_image=player_image player_image=player_image,
player_headshot=player_headshot
) )
async def load_team_lineup_with_player_data( async def load_team_lineup_with_player_data(
@ -152,11 +156,13 @@ class LineupService:
for p in lineup_entries: for p in lineup_entries:
player_name = None player_name = None
player_image = None 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] 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 = player_data.get(p.player_id) # type: ignore[arg-type]
player_name = player.name player_name = player.name
player_image = player.get_image_url() player_image = player.get_image_url()
player_headshot = player.headshot
players.append(LineupPlayerState( players.append(LineupPlayerState(
lineup_id=p.id, # type: ignore[arg-type] lineup_id=p.id, # type: ignore[arg-type]
@ -166,7 +172,8 @@ class LineupService:
is_active=p.is_active, # type: ignore[arg-type] is_active=p.is_active, # type: ignore[arg-type]
is_starter=p.is_starter, # type: ignore[arg-type] is_starter=p.is_starter, # type: ignore[arg-type]
player_name=player_name, player_name=player_name,
player_image=player_image player_image=player_image,
player_headshot=player_headshot
)) ))
return TeamLineupState(team_id=team_id, players=players) return TeamLineupState(team_id=team_id, players=players)

View File

@ -15,6 +15,7 @@ from app.core.validators import ValidationError as GameValidationError
from app.config.result_charts import PlayOutcome from app.config.result_charts import PlayOutcome
from app.database.operations import DatabaseOperations from app.database.operations import DatabaseOperations
from app.services.sba_api_client import sba_api_client from app.services.sba_api_client import sba_api_client
from app.services.lineup_service import lineup_service
logger = logging.getLogger(f'{__name__}.handlers') logger = logging.getLogger(f'{__name__}.handlers')
@ -1022,7 +1023,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"player": { "player": {
"id": p.card_id, "id": p.card_id,
"name": p.player_name or f"Player #{p.card_id}", "name": p.player_name or f"Player #{p.card_id}",
"image": p.player_image or "" "image": p.player_image or "",
"headshot": p.player_headshot or ""
} }
} }
for p in lineup.players if p.is_active for p in lineup.players if p.is_active
@ -1031,13 +1033,22 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
) )
logger.info(f"Lineup data sent for game {game_id}, team {team_id}") logger.info(f"Lineup data sent for game {game_id}, team {team_id}")
else: else:
# Lineup not in cache - try to load from database # Lineup not in cache - try to load from database with player data
db_ops = DatabaseOperations() # Get league_id from game state or database
lineup_entries = await db_ops.get_active_lineup(game_id, team_id) state = state_manager.get_state(game_id)
league_id = state.league_id if state else "sba"
if lineup_entries: lineup_state = await lineup_service.load_team_lineup_with_player_data(
# Note: Player data not available when loading directly from DB game_id=game_id,
# This is a cache miss scenario - normally lineups are cached with player data team_id=team_id,
league_id=league_id
)
if lineup_state:
# Cache the lineup for future requests
state_manager.set_lineup(game_id, team_id, lineup_state)
# Send lineup data with player info
await manager.emit_to_user( await manager.emit_to_user(
sid, sid,
"lineup_data", "lineup_data",
@ -1046,23 +1057,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"team_id": team_id, "team_id": team_id,
"players": [ "players": [
{ {
"lineup_id": entry.id, # type: ignore[assignment] "lineup_id": p.lineup_id,
"card_id": entry.card_id or entry.player_id, # type: ignore[assignment] "card_id": p.card_id,
"position": entry.position, # type: ignore[assignment] "position": p.position,
"batting_order": entry.batting_order, # type: ignore[assignment] "batting_order": p.batting_order,
"is_active": entry.is_active, # type: ignore[assignment] "is_active": p.is_active,
"is_starter": entry.is_starter, # type: ignore[assignment] "is_starter": p.is_starter,
"player": { "player": {
"id": entry.card_id or entry.player_id, # type: ignore[assignment] "id": p.card_id,
"name": f"Player #{entry.card_id or entry.player_id}", # type: ignore[assignment] "name": p.player_name or f"Player #{p.card_id}",
"image": "" "image": p.player_image or ""
} }
} }
for entry in lineup_entries for p in lineup_state.players if p.is_active
] ]
} }
) )
logger.info(f"Lineup data loaded from DB for game {game_id}, team {team_id} (without player details)") logger.info(f"Lineup data loaded from DB with player data for game {game_id}, team {team_id}")
else: else:
await manager.emit_to_user( await manager.emit_to_user(
sid, sid,

View File

@ -11,8 +11,8 @@
<!-- Pitcher Image/Badge --> <!-- Pitcher Image/Badge -->
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden"> <div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
<img <img
v-if="pitcherPlayer?.image" v-if="pitcherPlayer?.headshot"
:src="pitcherPlayer.image" :src="pitcherPlayer.headshot"
:alt="pitcherName" :alt="pitcherName"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
@ -52,8 +52,8 @@
<!-- Batter Image/Badge --> <!-- Batter Image/Badge -->
<div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden"> <div class="w-12 h-12 rounded-full flex items-center justify-center shadow-lg flex-shrink-0 overflow-hidden">
<img <img
v-if="batterPlayer?.image" v-if="batterPlayer?.headshot"
:src="batterPlayer.image" :src="batterPlayer.headshot"
:alt="batterName" :alt="batterName"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
@ -90,8 +90,8 @@
<!-- Pitcher Image/Badge --> <!-- Pitcher Image/Badge -->
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden"> <div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
<img <img
v-if="pitcherPlayer?.image" v-if="pitcherPlayer?.headshot"
:src="pitcherPlayer.image" :src="pitcherPlayer.headshot"
:alt="pitcherName" :alt="pitcherName"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
@ -124,8 +124,8 @@
<!-- Batter Image/Badge --> <!-- Batter Image/Badge -->
<div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden"> <div class="w-16 h-16 rounded-full flex items-center justify-center shadow-xl flex-shrink-0 overflow-hidden">
<img <img
v-if="batterPlayer?.image" v-if="batterPlayer?.headshot"
:src="batterPlayer.image" :src="batterPlayer.headshot"
:alt="batterName" :alt="batterName"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />

View File

@ -61,7 +61,7 @@
<div v-else class="bench-grid"> <div v-else class="bench-grid">
<button <button
v-for="player in filteredBenchPlayers" v-for="player in filteredBenchPlayers"
:key="player.id" :key="player.lineup_id"
:class="[ :class="[
'bench-player-card', 'bench-player-card',
selectedPlayerId === player.player.id ? 'bench-player-selected' : 'bench-player-default' selectedPlayerId === player.player.id ? 'bench-player-selected' : 'bench-player-default'
@ -194,7 +194,7 @@ const handleSubmit = () => {
} }
emit('submit', { emit('submit', {
playerOutLineupId: props.playerOut.id, playerOutLineupId: props.playerOut.lineup_id,
playerInCardId: selectedPlayer.value.player.id, playerInCardId: selectedPlayer.value.player.id,
newPosition: selectedPosition.value, newPosition: selectedPosition.value,
teamId: props.teamId, teamId: props.teamId,

View File

@ -36,7 +36,7 @@
<div v-else class="bench-grid"> <div v-else class="bench-grid">
<button <button
v-for="player in availableBenchPlayers" v-for="player in availableBenchPlayers"
:key="player.id" :key="player.lineup_id"
:class="[ :class="[
'bench-player-card', 'bench-player-card',
selectedPlayerId === player.player.id ? 'bench-player-selected' : 'bench-player-default' selectedPlayerId === player.player.id ? 'bench-player-selected' : 'bench-player-default'
@ -138,7 +138,7 @@ const handleSubmit = () => {
if (!canSubmit.value || !props.playerOut || !selectedPlayer.value) return if (!canSubmit.value || !props.playerOut || !selectedPlayer.value) return
emit('submit', { emit('submit', {
playerOutLineupId: props.playerOut.id, playerOutLineupId: props.playerOut.lineup_id,
playerInCardId: selectedPlayer.value.player.id, playerInCardId: selectedPlayer.value.player.id,
teamId: props.teamId, teamId: props.teamId,
}) })

View File

@ -171,7 +171,7 @@ const handleSubmit = () => {
} }
emit('submit', { emit('submit', {
playerOutLineupId: props.currentPitcher.id, playerOutLineupId: props.currentPitcher.lineup_id,
playerInCardId: selectedPitcher.value.player.id, playerInCardId: selectedPitcher.value.player.id,
teamId: props.teamId, teamId: props.teamId,
}) })

View File

@ -78,7 +78,7 @@
<div class="player-grid"> <div class="player-grid">
<button <button
v-for="player in activeFielders" v-for="player in activeFielders"
:key="player.id" :key="player.lineup_id"
class="player-button" class="player-button"
@click="selectDefensivePlayer(player)" @click="selectDefensivePlayer(player)"
> >

View File

@ -335,7 +335,7 @@ export const useGameStore = defineStore('game', () => {
*/ */
function findPlayerInLineup(lineupId: number): Lineup | undefined { function findPlayerInLineup(lineupId: number): Lineup | undefined {
return [...homeLineup.value, ...awayLineup.value].find( return [...homeLineup.value, ...awayLineup.value].find(
p => p.id === lineupId p => p.lineup_id === lineupId
) )
} }

View File

@ -48,26 +48,31 @@ export interface SbaPlayer {
/** /**
* Lineup entry - player assignment in a game * Lineup entry - player assignment in a game
* Backend: Lineup (db model) * Backend: Lineup (db model) via WebSocket lineup_data event
*/ */
export interface Lineup { export interface Lineup {
id: number lineup_id: number // Unique lineup entry ID (was 'id')
game_id: string card_id: number // Player/card ID
team_id: number game_id?: string // Optional - not always sent in events
player_id: number team_id?: number // Optional - not always sent in events
position: string position: string
batting_order: number | null batting_order: number | null
is_starter: boolean is_starter: boolean
is_active: boolean is_active: boolean
entered_inning: number entered_inning?: number // Optional - not always sent in events
// Substitution tracking // Substitution tracking (optional - not always sent in events)
replacing_id: number | null replacing_id?: number | null
after_play: number | null after_play?: number | null
is_fatigued: boolean is_fatigued?: boolean
// Player data (embedded) // Player data (embedded from SBA API)
player: SbaPlayer player: {
id: number
name: string
image: string // Card image
headshot: string // Headshot for UI circles
}
} }
/** /**