diff --git a/CLAUDE.md b/CLAUDE.md index ea8f34b..b570267 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,13 +92,15 @@ strat-gameplay-webapp/ The entire stack runs in Docker with a single command. No local Python or Node.js required. -```bash -# Development (hot-reload enabled) -./start.sh dev +> **⚠️ ALWAYS USE PROD MODE**: Discord OAuth does not work in dev mode due to cookie/CORS configuration. Since this system isn't live yet, always build and run with `prod` mode for testing. -# Production (optimized build) +```bash +# Production (optimized build) - USE THIS ./start.sh prod +# Development - DO NOT USE (auth broken) +# ./start.sh dev + # Stop all services ./start.sh stop @@ -116,8 +118,8 @@ The entire stack runs in Docker with a single command. No local Python or Node.j | Mode | Backend | Frontend | Use Case | |------|---------|----------|----------| -| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | Active development | -| `prod` | Production build | SSR optimized build | Demo/deployment | +| `prod` | Production build | SSR optimized build | **Always use this** - auth works correctly | +| `dev` | Hot-reload (uvicorn --reload) | Hot-reload (nuxt dev) | ❌ Auth broken - do not use | ### Service URLs diff --git a/backend/app/api/routes/games.py b/backend/app/api/routes/games.py index 785d51a..6fc5db7 100644 --- a/backend/app/api/routes/games.py +++ b/backend/app/api/routes/games.py @@ -320,12 +320,38 @@ async def create_game(request: CreateGameRequest): status_code=400, detail="Home and away teams must be different" ) + # Fetch team display info from SBA API and build game_metadata + # This persists team data so it's always available regardless of season + teams_data = await sba_api_client.get_teams_by_ids( + [request.home_team_id, request.away_team_id], season=request.season + ) + home_team_data = teams_data.get(request.home_team_id, {}) + away_team_data = teams_data.get(request.away_team_id, {}) + + game_metadata = { + "home_team": { + "lname": home_team_data.get("lname"), + "sname": home_team_data.get("sname"), + "abbrev": home_team_data.get("abbrev"), + "color": home_team_data.get("color"), + "thumbnail": home_team_data.get("thumbnail"), + }, + "away_team": { + "lname": away_team_data.get("lname"), + "sname": away_team_data.get("sname"), + "abbrev": away_team_data.get("abbrev"), + "color": away_team_data.get("color"), + "thumbnail": away_team_data.get("thumbnail"), + }, + } + # Create game in state manager (in-memory) state = await state_manager.create_game( game_id=game_id, league_id=request.league_id, home_team_id=request.home_team_id, away_team_id=request.away_team_id, + game_metadata=game_metadata, ) # Save to database @@ -337,6 +363,7 @@ async def create_game(request: CreateGameRequest): away_team_id=request.away_team_id, game_mode="friendly" if not request.is_ai_opponent else "ai", visibility="public", + game_metadata=game_metadata, ) logger.info( @@ -407,6 +434,31 @@ async def quick_create_game( f"(creator: {creator_discord_id}, custom_teams: {use_custom_teams})" ) + # Fetch team display info from SBA API and build game_metadata + # This persists team data so it's always available regardless of season + teams_data = await sba_api_client.get_teams_by_ids( + [home_team_id, away_team_id], season=13 + ) + home_team_data = teams_data.get(home_team_id, {}) + away_team_data = teams_data.get(away_team_id, {}) + + game_metadata = { + "home_team": { + "lname": home_team_data.get("lname"), + "sname": home_team_data.get("sname"), + "abbrev": home_team_data.get("abbrev"), + "color": home_team_data.get("color"), + "thumbnail": home_team_data.get("thumbnail"), + }, + "away_team": { + "lname": away_team_data.get("lname"), + "sname": away_team_data.get("sname"), + "abbrev": away_team_data.get("abbrev"), + "color": away_team_data.get("color"), + "thumbnail": away_team_data.get("thumbnail"), + }, + } + # Create game in state manager state = await state_manager.create_game( game_id=game_id, @@ -414,6 +466,7 @@ async def quick_create_game( home_team_id=home_team_id, away_team_id=away_team_id, creator_discord_id=creator_discord_id, + game_metadata=game_metadata, ) # Save to database @@ -426,6 +479,7 @@ async def quick_create_game( game_mode="friendly", visibility="public", schedule_game_id=schedule_game_id, + game_metadata=game_metadata, ) if use_custom_teams: @@ -1023,6 +1077,16 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest): logger.info(f"Added {player_count} players to team {request.team_id} lineup") + # Load lineup from DB and cache in state_manager for subsequent checks + team_lineup = await lineup_service.load_team_lineup_with_player_data( + game_id=game_uuid, + team_id=request.team_id, + league_id=state.league_id, + ) + if team_lineup: + state_manager.set_lineup(game_uuid, request.team_id, team_lineup) + logger.info(f"Cached lineup for team {request.team_id} in state_manager") + # Check if both teams now have lineups home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id) away_lineup = state_manager.get_lineup(game_uuid, state.away_team_id) diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 84328a3..7dc5c69 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -18,6 +18,7 @@ class TeamResponse(BaseModel): sname: str lname: str color: str | None = None + thumbnail: str | None = None manager_legacy: str | None = None gmid: str | None = None gmid2: str | None = None @@ -46,6 +47,7 @@ async def get_teams(season: int = Query(..., description="Season number (e.g., 3 sname=team["sname"], lname=team["lname"], color=team.get("color"), + thumbnail=team.get("thumbnail"), manager_legacy=team.get("manager_legacy"), gmid=team.get("gmid"), gmid2=team.get("gmid2"), diff --git a/backend/app/core/state_manager.py b/backend/app/core/state_manager.py index 9cfba0c..40f2586 100644 --- a/backend/app/core/state_manager.py +++ b/backend/app/core/state_manager.py @@ -94,6 +94,7 @@ class StateManager: away_team_is_ai: bool = False, auto_mode: bool = False, creator_discord_id: str | None = None, + game_metadata: dict | None = None, ) -> GameState: """ Create a new game state in memory. @@ -106,6 +107,7 @@ class StateManager: home_team_is_ai: Whether home team is AI-controlled away_team_is_ai: Whether away team is AI-controlled auto_mode: True = auto-generate outcomes (PD only), False = manual submissions + game_metadata: Optional dict with team display info (lname, abbrev, color, thumbnail) Returns: Newly created GameState @@ -127,6 +129,10 @@ class StateManager: lineup_id=0, card_id=0, position="DH", batting_order=None ) + # Extract team display info from metadata + home_meta = game_metadata.get("home_team", {}) if game_metadata else {} + away_meta = game_metadata.get("away_team", {}) if game_metadata else {} + state = GameState( game_id=game_id, league_id=league_id, @@ -137,6 +143,15 @@ class StateManager: auto_mode=auto_mode, creator_discord_id=creator_discord_id, current_batter=placeholder_batter, # Will be replaced by _prepare_next_play() when game starts + # Team display info from metadata + home_team_name=home_meta.get("lname"), + home_team_abbrev=home_meta.get("abbrev"), + home_team_color=home_meta.get("color"), + home_team_thumbnail=home_meta.get("thumbnail"), + away_team_name=away_meta.get("lname"), + away_team_abbrev=away_meta.get("abbrev"), + away_team_color=away_meta.get("color"), + away_team_thumbnail=away_meta.get("thumbnail"), ) self._states[game_id] = state diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index bd83efe..3a773b7 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -103,6 +103,7 @@ class DatabaseOperations: away_team_is_ai: bool = False, ai_difficulty: str | None = None, schedule_game_id: int | None = None, + game_metadata: dict | None = None, ) -> Game: """ Create new game in database. @@ -118,6 +119,7 @@ class DatabaseOperations: away_team_is_ai: Whether away team is AI ai_difficulty: AI difficulty if applicable schedule_game_id: External schedule game ID for linking (SBA, PD, etc.) + game_metadata: Optional dict with team display info (lname, abbrev, color, thumbnail) Returns: Created Game model @@ -137,6 +139,7 @@ class DatabaseOperations: away_team_is_ai=away_team_is_ai, ai_difficulty=ai_difficulty, schedule_game_id=schedule_game_id, + game_metadata=game_metadata or {}, status="pending", ) session.add(game) diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 1234b50..87ce95c 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -396,6 +396,16 @@ class GameState(BaseModel): home_team_is_ai: bool = False away_team_is_ai: bool = False + # Team display info (for UI - fetched from league API when game created) + home_team_name: str | None = None # e.g., "Chicago Cyclones" + home_team_abbrev: str | None = None # e.g., "CHC" + home_team_color: str | None = None # e.g., "ff5349" (no # prefix) + home_team_thumbnail: str | None = None # Team logo URL + away_team_name: str | None = None + away_team_abbrev: str | None = None + away_team_color: str | None = None + away_team_thumbnail: str | None = None + # Creator (for demo/testing - creator can control home team) creator_discord_id: str | None = None diff --git a/frontend-sba/CLAUDE.md b/frontend-sba/CLAUDE.md index 85d9229..39ae917 100644 --- a/frontend-sba/CLAUDE.md +++ b/frontend-sba/CLAUDE.md @@ -55,9 +55,13 @@ frontend-sba/ ## Development +> **⚠️ ALWAYS USE PROD MODE**: Run the full stack via Docker with `./start.sh prod` from the project root. Dev mode (`./start.sh dev`) has broken Discord OAuth due to cookie/CORS issues. The system isn't live yet, so always use prod for testing. + ```bash -npm install # First time -npm run dev # Dev server at http://localhost:3000 +# From project root - use this for testing +./start.sh prod + +# Local commands (for type checking only, not running) npm run type-check # Check types npm run lint # Lint code ``` diff --git a/frontend-sba/components/Game/CurrentSituation.vue b/frontend-sba/components/Game/CurrentSituation.vue index ce6b6fc..081277d 100644 --- a/frontend-sba/components/Game/CurrentSituation.vue +++ b/frontend-sba/components/Game/CurrentSituation.vue @@ -11,13 +11,14 @@
-
- P +
+ {{ getPlayerFallbackInitial(pitcherPlayer) }}
@@ -52,13 +53,14 @@
-
- B +
+ {{ getPlayerFallbackInitial(batterPlayer) }}
@@ -90,13 +92,14 @@
-
- P +
+ {{ getPlayerFallbackInitial(pitcherPlayer) }}
@@ -124,13 +127,14 @@
-
- B +
+ {{ getPlayerFallbackInitial(batterPlayer) }}
@@ -221,6 +225,25 @@ const pitcherName = computed(() => { if (!props.currentPitcher) return 'Unknown Pitcher' return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}` }) + +// Get player preview image with fallback priority: headshot > vanity_card > null +function getPlayerPreviewImage(player: { headshot?: string | null; vanity_card?: string | null } | null): string | null { + if (!player) return null + return player.headshot || player.vanity_card || null +} + +// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV") +// Ignores common suffixes like Jr, Sr, II, III, IV +function getPlayerFallbackInitial(player: { name: string } | null): string { + if (!player) return '?' + const suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v'] + const parts = player.name.trim().split(/\s+/).filter( + part => !suffixes.includes(part.toLowerCase()) + ) + if (parts.length === 0) return '?' + if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase() +}