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)
)
# Get placeholder current_batter (required field)
# _prepare_next_play() will set the correct batter after recovery
# Determine fielding team based on current half
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
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'])
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 no batter found, use first available lineup from batting team
if not current_batter_placeholder:
for lineup in lineups:
if lineup.get('team_id') == batting_team_id and lineup.get('is_active'):
current_batter_placeholder = get_lineup_player(lineup['id'])
break
# 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")
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(
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_id=home_team_id,
away_team_id=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'),
half=current_half,
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()
current_batter=current_batter_placeholder,
current_pitcher=current_pitcher,
current_catcher=current_catcher
)
# 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_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)
position_rating: Optional['PositionRating'] = None

View File

@ -30,7 +30,8 @@ class LineupEntryWithPlayer:
is_active: bool
# Player data from API
player_name: str
player_image: str
player_image: str # Card image
player_headshot: str # Headshot for UI circles
class LineupService:
@ -88,11 +89,13 @@ class LineupService:
# Step 2: Fetch player data from SBA API
player_name = f"Player #{player_id}"
player_image = ""
player_headshot = ""
try:
player = await sba_api_client.get_player(player_id)
player_name = player.name
player_image = player.get_image_url()
player_headshot = player.headshot or ""
logger.info(f"Loaded player data for {player_id}: {player_name}")
except Exception as e:
logger.warning(f"Failed to fetch player data for {player_id}: {e}")
@ -107,7 +110,8 @@ class LineupService:
is_starter=is_starter,
is_active=True,
player_name=player_name,
player_image=player_image
player_image=player_image,
player_headshot=player_headshot
)
async def load_team_lineup_with_player_data(
@ -152,11 +156,13 @@ class LineupService:
for p in lineup_entries:
player_name = 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]
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
players.append(LineupPlayerState(
lineup_id=p.id, # type: ignore[arg-type]
@ -166,7 +172,8 @@ class LineupService:
is_active=p.is_active, # type: ignore[arg-type]
is_starter=p.is_starter, # type: ignore[arg-type]
player_name=player_name,
player_image=player_image
player_image=player_image,
player_headshot=player_headshot
))
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.database.operations import DatabaseOperations
from app.services.sba_api_client import sba_api_client
from app.services.lineup_service import lineup_service
logger = logging.getLogger(f'{__name__}.handlers')
@ -1022,7 +1023,8 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"player": {
"id": 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
@ -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}")
else:
# Lineup not in cache - try to load from database
db_ops = DatabaseOperations()
lineup_entries = await db_ops.get_active_lineup(game_id, team_id)
# Lineup not in cache - try to load from database with player data
# Get league_id from game state or database
state = state_manager.get_state(game_id)
league_id = state.league_id if state else "sba"
if lineup_entries:
# Note: Player data not available when loading directly from DB
# This is a cache miss scenario - normally lineups are cached with player data
lineup_state = await lineup_service.load_team_lineup_with_player_data(
game_id=game_id,
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(
sid,
"lineup_data",
@ -1046,23 +1057,23 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None:
"team_id": team_id,
"players": [
{
"lineup_id": entry.id, # type: ignore[assignment]
"card_id": entry.card_id or entry.player_id, # type: ignore[assignment]
"position": entry.position, # type: ignore[assignment]
"batting_order": entry.batting_order, # type: ignore[assignment]
"is_active": entry.is_active, # type: ignore[assignment]
"is_starter": entry.is_starter, # type: ignore[assignment]
"lineup_id": p.lineup_id,
"card_id": p.card_id,
"position": p.position,
"batting_order": p.batting_order,
"is_active": p.is_active,
"is_starter": p.is_starter,
"player": {
"id": entry.card_id or entry.player_id, # type: ignore[assignment]
"name": f"Player #{entry.card_id or entry.player_id}", # type: ignore[assignment]
"image": ""
"id": p.card_id,
"name": p.player_name or f"Player #{p.card_id}",
"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:
await manager.emit_to_user(
sid,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -335,7 +335,7 @@ export const useGameStore = defineStore('game', () => {
*/
function findPlayerInLineup(lineupId: number): Lineup | undefined {
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
* Backend: Lineup (db model)
* Backend: Lineup (db model) via WebSocket lineup_data event
*/
export interface Lineup {
id: number
game_id: string
team_id: number
player_id: number
lineup_id: number // Unique lineup entry ID (was 'id')
card_id: number // Player/card ID
game_id?: string // Optional - not always sent in events
team_id?: number // Optional - not always sent in events
position: string
batting_order: number | null
is_starter: boolean
is_active: boolean
entered_inning: number
entered_inning?: number // Optional - not always sent in events
// Substitution tracking
replacing_id: number | null
after_play: number | null
is_fatigued: boolean
// Substitution tracking (optional - not always sent in events)
replacing_id?: number | null
after_play?: number | null
is_fatigued?: boolean
// Player data (embedded)
player: SbaPlayer
// Player data (embedded from SBA API)
player: {
id: number
name: string
image: string // Card image
headshot: string // Headshot for UI circles
}
}
/**