diff --git a/backend/alembic/versions/006_add_player_positions_to_roster_links.py b/backend/alembic/versions/006_add_player_positions_to_roster_links.py new file mode 100644 index 0000000..3cc32cf --- /dev/null +++ b/backend/alembic/versions/006_add_player_positions_to_roster_links.py @@ -0,0 +1,33 @@ +"""add player_positions to roster_links + +Revision ID: 006 +Revises: 62bd3195c64c +Create Date: 2026-01-17 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision: str = '006' +down_revision: Union[str, None] = '62bd3195c64c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add player_positions column for storing player's natural positions + # This allows the frontend to filter bench players by position type (batters vs pitchers) + # Example values: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"] (two-way player) + op.add_column( + 'roster_links', + sa.Column('player_positions', JSONB, nullable=True, server_default='[]') + ) + + +def downgrade() -> None: + op.drop_column('roster_links', 'player_positions') diff --git a/backend/alembic/versions/007_add_player_data_to_roster_links.py b/backend/alembic/versions/007_add_player_data_to_roster_links.py new file mode 100644 index 0000000..bf76398 --- /dev/null +++ b/backend/alembic/versions/007_add_player_data_to_roster_links.py @@ -0,0 +1,31 @@ +"""Add player_data JSONB column to roster_links for caching player names/images. + +Revision ID: 007 +Revises: 006 +Create Date: 2026-01-17 + +This stores player name, image, and headshot at lineup submission time, +eliminating the need for runtime API calls when loading bench players. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision = "007" +down_revision = "006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "roster_links", + sa.Column("player_data", JSONB, nullable=True, server_default="{}") + ) + + +def downgrade() -> None: + op.drop_column("roster_links", "player_data") diff --git a/backend/app/api/routes/games.py b/backend/app/api/routes/games.py index 6fc5db7..eb6000d 100644 --- a/backend/app/api/routes/games.py +++ b/backend/app/api/routes/games.py @@ -771,6 +771,12 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest): ) +class BenchPlayerRequest(BaseModel): + """Single bench player in lineup request""" + + player_id: int = Field(..., description="SBA player ID") + + class SubmitTeamLineupRequest(BaseModel): """Request model for submitting a single team's lineup""" @@ -778,6 +784,9 @@ class SubmitTeamLineupRequest(BaseModel): lineup: list[LineupPlayerRequest] = Field( ..., min_length=9, max_length=10, description="Team's starting lineup (9-10 players)" ) + bench: list[BenchPlayerRequest] = Field( + default_factory=list, description="Bench players (not in starting lineup)" + ) @field_validator("lineup") @classmethod @@ -1062,7 +1071,49 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest): detail=f"Lineup already submitted for team {request.team_id}" ) - # Process lineup + # Step 1: Collect all player IDs (starters + bench) for batch API fetch + all_player_ids = [p.player_id for p in request.lineup] + all_player_ids.extend([p.player_id for p in request.bench]) + + # Step 2: Fetch all player data from SBA API to get positions + player_data = {} + if all_player_ids: + try: + player_data = await sba_api_client.get_players_batch(all_player_ids) + logger.info(f"Fetched {len(player_data)}/{len(all_player_ids)} players from SBA API") + except Exception as e: + logger.warning(f"Failed to fetch player data from SBA API: {e}") + # Continue - roster entries will have empty positions + + # Step 3: Add ALL players to RosterLink with their natural positions and cached data + db_ops = DatabaseOperations() + roster_count = 0 + for player_id in all_player_ids: + player_positions: list[str] = [] + cached_player_data: dict | None = None + + if player_id in player_data: + player = player_data[player_id] + player_positions = player.get_positions() + # Cache essential player data to avoid runtime API calls + cached_player_data = { + "name": player.name, + "image": player.get_image_url(), + "headshot": player.headshot or "", + } + + await db_ops.add_sba_roster_player( + game_id=game_uuid, + player_id=player_id, + team_id=request.team_id, + player_positions=player_positions, + player_data=cached_player_data, + ) + roster_count += 1 + + logger.info(f"Added {roster_count} players to roster for team {request.team_id}") + + # Step 4: Add only STARTERS to active Lineup table player_count = 0 for player in request.lineup: await lineup_service.add_sba_player_to_lineup( @@ -1075,7 +1126,10 @@ async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest): ) player_count += 1 - logger.info(f"Added {player_count} players to team {request.team_id} lineup") + logger.info(f"Added {player_count} starters to team {request.team_id} lineup") + + # Note: Bench players are NOT added to Lineup - they're derived from + # RosterLink players not in active Lineup via get_bench_players() # Load lineup from DB and cache in state_manager for subsequent checks team_lineup = await lineup_service.load_team_lineup_with_player_data( diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index 75c718f..f995718 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -290,12 +290,12 @@ class DatabaseOperations: position=position, batting_order=batting_order, is_starter=is_starter, - is_active=True, + is_active=is_starter, # Bench players (is_starter=False) are inactive ) session.add(lineup) await session.flush() await session.refresh(lineup) - logger.debug(f"Added SBA player {player_id} to lineup in game {game_id}") + logger.debug(f"Added SBA player {player_id} to lineup in game {game_id} (active={is_starter})") return lineup async def get_active_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]: @@ -325,6 +325,35 @@ class DatabaseOperations: ) return lineups + async def get_full_lineup(self, game_id: UUID, team_id: int) -> list[Lineup]: + """ + Get full lineup for team including bench players. + + Args: + game_id: Game identifier + team_id: Team identifier + + Returns: + List of all Lineup models (active + bench), active sorted by batting order first + """ + async with self._get_session() as session: + result = await session.execute( + select(Lineup) + .where( + Lineup.game_id == game_id, + Lineup.team_id == team_id, + ) + .order_by(Lineup.is_active.desc(), Lineup.batting_order) + ) + lineups = list(result.scalars().all()) + active_count = sum(1 for l in lineups if l.is_active) + bench_count = len(lineups) - active_count + logger.debug( + f"Retrieved {len(lineups)} lineup entries for team {team_id} " + f"({active_count} active, {bench_count} bench)" + ) + return lineups + async def create_substitution( self, game_id: UUID, @@ -640,7 +669,12 @@ class DatabaseOperations: ) async def add_sba_roster_player( - self, game_id: UUID, player_id: int, team_id: int + self, + game_id: UUID, + player_id: int, + team_id: int, + player_positions: list[str] | None = None, + player_data: dict | None = None, ) -> SbaRosterLinkData: """ Add an SBA player to game roster. @@ -649,6 +683,9 @@ class DatabaseOperations: game_id: Game identifier player_id: Player identifier team_id: Team identifier + player_positions: List of natural positions (e.g., ["SS", "2B", "3B"] or ["SP", "RP"]) + Used for substitution UI filtering (batters vs pitchers) + player_data: Cached player info {name, image, headshot} to avoid runtime API calls Returns: SbaRosterLinkData with populated id @@ -658,18 +695,27 @@ class DatabaseOperations: """ async with self._get_session() as session: roster_link = RosterLink( - game_id=game_id, player_id=player_id, team_id=team_id + game_id=game_id, + player_id=player_id, + team_id=team_id, + player_positions=player_positions or [], + player_data=player_data or {}, ) session.add(roster_link) await session.flush() await session.refresh(roster_link) - logger.info(f"Added SBA player {player_id} to roster for game {game_id}") + logger.info( + f"Added SBA player {player_id} to roster for game {game_id} " + f"(positions: {player_positions or []})" + ) return SbaRosterLinkData( id=roster_link.id, game_id=roster_link.game_id, player_id=roster_link.player_id, team_id=roster_link.team_id, + player_positions=roster_link.player_positions or [], + player_data=roster_link.player_data, ) async def get_pd_roster( @@ -717,7 +763,7 @@ class DatabaseOperations: team_id: Optional team filter Returns: - List of SbaRosterLinkData + List of SbaRosterLinkData with player_positions """ async with self._get_session() as session: query = select(RosterLink).where( @@ -736,10 +782,70 @@ class DatabaseOperations: game_id=link.game_id, player_id=link.player_id, team_id=link.team_id, + player_positions=link.player_positions or [], + player_data=link.player_data, ) for link in roster_links ] + async def get_bench_players( + self, game_id: UUID, team_id: int + ) -> list[SbaRosterLinkData]: + """ + Get bench players for a team (roster players not in active lineup). + + This queries RosterLink for players that are NOT in the active Lineup, + including their player_positions for UI filtering (batters vs pitchers). + + Args: + game_id: Game identifier + team_id: Team identifier + + Returns: + List of SbaRosterLinkData for bench players with is_pitcher/is_batter computed + """ + async with self._get_session() as session: + # Get player_ids that are in the active lineup + active_lineup_query = ( + select(Lineup.player_id) + .where( + Lineup.game_id == game_id, + Lineup.team_id == team_id, + Lineup.is_active == True, + Lineup.player_id.is_not(None), # SBA players + ) + ) + + # Get roster players NOT in active lineup + bench_query = ( + select(RosterLink) + .where( + RosterLink.game_id == game_id, + RosterLink.team_id == team_id, + RosterLink.player_id.is_not(None), # SBA players + RosterLink.player_id.not_in(active_lineup_query), + ) + ) + + result = await session.execute(bench_query) + bench_links = result.scalars().all() + + logger.debug( + f"Retrieved {len(bench_links)} bench players for team {team_id} in game {game_id}" + ) + + return [ + SbaRosterLinkData( + id=link.id, + game_id=link.game_id, + player_id=link.player_id, + team_id=link.team_id, + player_positions=link.player_positions or [], + player_data=link.player_data, + ) + for link in bench_links + ] + async def remove_roster_entry(self, roster_id: int) -> None: """ Remove a roster entry by ID. diff --git a/backend/app/models/db_models.py b/backend/app/models/db_models.py index 3b0904a..6970e1b 100644 --- a/backend/app/models/db_models.py +++ b/backend/app/models/db_models.py @@ -43,6 +43,11 @@ class RosterLink(Base): SBA League: Uses player_id to track which players are rostered Exactly one of card_id or player_id must be populated per row. + + The player_positions field stores the player's natural positions from the API + for use in substitution UI filtering (batters vs pitchers). This supports + two-way players like Shohei Ohtani who have both pitching and batting positions. + Example: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"] """ __tablename__ = "roster_links" @@ -60,6 +65,14 @@ class RosterLink(Base): player_id = Column(Integer, nullable=True) # SBA only team_id = Column(Integer, nullable=False, index=True) + # Player's natural positions from API (for substitution UI filtering) + # Supports two-way players with both pitching and batting positions + player_positions = Column(JSONB, default=list) + + # Cached player data (name, image, headshot) to avoid runtime API calls + # Populated at lineup submission time + player_data = Column(JSONB, default=dict) + # Relationships game = relationship("Game", back_populates="roster_links") diff --git a/backend/app/models/roster_models.py b/backend/app/models/roster_models.py index c99cf47..c9e6e3e 100644 --- a/backend/app/models/roster_models.py +++ b/backend/app/models/roster_models.py @@ -8,7 +8,7 @@ Provides league-specific type-safe models for roster operations: from abc import ABC, abstractmethod from uuid import UUID -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, computed_field, field_validator class BaseRosterLinkData(BaseModel, ABC): @@ -62,9 +62,18 @@ class SbaRosterLinkData(BaseRosterLinkData): Used for SBA league games where rosters are composed of players. Players are identified directly by player_id without a card system. + + The player_positions field stores the player's natural positions from the API + for use in substitution UI filtering (batters vs pitchers). This supports + two-way players like Shohei Ohtani who have both pitching and batting positions. + + The player_data field caches essential SbaPlayer fields (name, image, headshot) + to avoid runtime API calls when loading bench players. """ player_id: int + player_positions: list[str] = [] + player_data: dict | None = None # Cached SbaPlayer fields: {name, image, headshot} @field_validator("player_id") @classmethod @@ -79,6 +88,34 @@ class SbaRosterLinkData(BaseRosterLinkData): def get_entity_type(self) -> str: return "player" + # Pitcher positions used for filtering + # CP = Closer Pitcher (alternate notation for CL) + _PITCHER_POSITIONS: set[str] = {"P", "SP", "RP", "CL", "CP"} + + @computed_field + @property + def is_pitcher(self) -> bool: + """True if player has any pitching position (supports two-way players) + + Examples: + - ["SP"] -> True + - ["SP", "DH"] -> True (two-way player like Ohtani) + - ["CF", "DH"] -> False + """ + return any(pos in self._PITCHER_POSITIONS for pos in self.player_positions) + + @computed_field + @property + def is_batter(self) -> bool: + """True if player has any non-pitching position (supports two-way players) + + Examples: + - ["CF", "DH"] -> True + - ["SP", "DH"] -> True (two-way player like Ohtani) + - ["SP"] -> False (pitcher-only) + """ + return any(pos not in self._PITCHER_POSITIONS for pos in self.player_positions) + class RosterLinkCreate(BaseModel): """Request model for creating a roster link""" @@ -87,6 +124,7 @@ class RosterLinkCreate(BaseModel): team_id: int card_id: int | None = None player_id: int | None = None + player_positions: list[str] = [] # Natural positions for substitution filtering @field_validator("team_id") @classmethod @@ -116,5 +154,8 @@ class RosterLinkCreate(BaseModel): if self.player_id is None: raise ValueError("player_id required for SBA roster") return SbaRosterLinkData( - game_id=self.game_id, team_id=self.team_id, player_id=self.player_id + game_id=self.game_id, + team_id=self.team_id, + player_id=self.player_id, + player_positions=self.player_positions, ) diff --git a/backend/app/websocket/handlers.py b/backend/app/websocket/handlers.py index e063bb8..0320eef 100644 --- a/backend/app/websocket/handlers.py +++ b/backend/app/websocket/handlers.py @@ -1394,6 +1394,116 @@ def register_handlers(sio: AsyncServer, manager: ConnectionManager) -> None: sid, "error", {"message": "Invalid lineup request"} ) + @sio.event + async def get_bench(sid, data): + """ + Get bench players for a team (roster players not in active lineup). + + This queries RosterLink for players that are NOT in the active Lineup, + including their natural positions for UI filtering (batters vs pitchers). + Supports two-way players with is_pitcher and is_batter computed properties. + + Event data: + game_id: UUID of the game + team_id: int - team to get bench for + + Emits: + bench_data: To requester with bench players including: + - player_positions: list of natural positions (e.g., ["SS", "2B"]) + - is_pitcher: true if player has pitching positions + - is_batter: true if player has batting positions + error: To requester if validation fails + """ + await manager.update_activity(sid) + + if not await rate_limiter.check_websocket_limit(sid): + await manager.emit_to_user( + sid, "error", {"message": "Rate limited. Please slow down.", "code": "RATE_LIMITED"} + ) + return + + try: + game_id_str = data.get("game_id") + if not game_id_str: + await manager.emit_to_user(sid, "error", {"message": "Missing game_id"}) + return + + try: + game_id = UUID(game_id_str) + except (ValueError, AttributeError): + await manager.emit_to_user( + sid, "error", {"message": "Invalid game_id format"} + ) + return + + team_id = data.get("team_id") + if team_id is None: + await manager.emit_to_user(sid, "error", {"message": "Missing team_id"}) + return + + # Query bench players from RosterLink (roster players not in active lineup) + # Player data (name, image, headshot) is cached in RosterLink - no API call needed + db_ops = DatabaseOperations() + bench_roster = await db_ops.get_bench_players(game_id, team_id) + + if bench_roster: + bench_players = [] + for roster in bench_roster: + # Use cached player_data from RosterLink (populated at lineup submission) + pdata = roster.player_data or {} + player_name = pdata.get("name", f"Player #{roster.player_id}") + player_image = pdata.get("image", "") + player_headshot = pdata.get("headshot", "") + + bench_players.append({ + "roster_id": roster.id, + "player_id": roster.player_id, + "player_positions": roster.player_positions, + "is_pitcher": roster.is_pitcher, + "is_batter": roster.is_batter, + "player": { + "id": roster.player_id, + "name": player_name, + "image": player_image, + "headshot": player_headshot, + # Include positions for frontend filtering (legacy support) + "pos_1": roster.player_positions[0] if roster.player_positions else None, + "pos_2": roster.player_positions[1] if len(roster.player_positions) > 1 else None, + "pos_3": roster.player_positions[2] if len(roster.player_positions) > 2 else None, + }, + }) + + await manager.emit_to_user( + sid, + "bench_data", + { + "game_id": str(game_id), + "team_id": team_id, + "players": bench_players, + }, + ) + logger.info( + f"Bench data sent for game {game_id}, team {team_id}: {len(bench_players)} players" + ) + else: + await manager.emit_to_user( + sid, + "bench_data", + {"game_id": str(game_id), "team_id": team_id, "players": []}, + ) + logger.info(f"No bench players found for game {game_id}, team {team_id}") + + except SQLAlchemyError as e: + logger.error(f"Database error in get_bench: {e}") + await manager.emit_to_user( + sid, "error", {"message": "Database error - please retry"} + ) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid data in get_bench request: {e}") + await manager.emit_to_user( + sid, "error", {"message": "Invalid bench request"} + ) + @sio.event async def submit_defensive_decision(sid, data): """ diff --git a/backend/tests/integration/database/test_operations.py b/backend/tests/integration/database/test_operations.py index 9fc8e40..77eedf5 100644 --- a/backend/tests/integration/database/test_operations.py +++ b/backend/tests/integration/database/test_operations.py @@ -736,6 +736,141 @@ class TestDatabaseOperationsRoster: roster = await db_ops.get_sba_roster(game_id) assert len(roster) == 0 + async def test_get_bench_players(self, db_ops, db_session): + """ + Test get_bench_players returns roster players NOT in active lineup. + + This verifies the RosterLink refactor where: + - RosterLink contains ALL eligible players with player_positions + - Lineup contains only ACTIVE players + - Bench = RosterLink players NOT IN Lineup + + Also tests computed is_pitcher/is_batter properties. + """ + 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_positions + # Player 101: Pitcher only + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=101, + team_id=team_id, + player_positions=["SP", "RP"] + ) + # Player 102: Batter only (shortstop) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=102, + team_id=team_id, + player_positions=["SS", "2B"] + ) + # Player 103: Two-way player (pitcher and DH) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=103, + team_id=team_id, + player_positions=["SP", "DH"] + ) + # Player 104: Outfielder (will be in active lineup) + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=104, + team_id=team_id, + player_positions=["CF", "RF"] + ) + + # Add player 104 to ACTIVE lineup (not bench) + await db_ops.add_sba_lineup_player( + game_id=game_id, + team_id=team_id, + player_id=104, + position="CF", + batting_order=1, + is_starter=True + ) + await db_session.flush() + + # Get bench players (should be 101, 102, 103 - NOT 104) + bench = await db_ops.get_bench_players(game_id, team_id) + + # Verify count + assert len(bench) == 3 + + # Verify player IDs (104 should NOT be in bench) + bench_player_ids = {p.player_id for p in bench} + assert bench_player_ids == {101, 102, 103} + assert 104 not in bench_player_ids + + # Verify computed properties for each player + bench_by_id = {p.player_id: p for p in bench} + + # Player 101: Pitcher only + assert bench_by_id[101].is_pitcher is True + assert bench_by_id[101].is_batter is False + assert bench_by_id[101].player_positions == ["SP", "RP"] + + # Player 102: Batter only + assert bench_by_id[102].is_pitcher is False + assert bench_by_id[102].is_batter is True + assert bench_by_id[102].player_positions == ["SS", "2B"] + + # Player 103: Two-way player (BOTH is_pitcher AND is_batter) + assert bench_by_id[103].is_pitcher is True + assert bench_by_id[103].is_batter is True + assert bench_by_id[103].player_positions == ["SP", "DH"] + + async def test_get_bench_players_empty(self, db_ops, db_session): + """ + Test get_bench_players returns empty list when all roster players are in lineup. + """ + 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 to roster + await db_ops.add_sba_roster_player( + game_id=game_id, + player_id=101, + team_id=team_id, + player_positions=["CF"] + ) + + # Add same player to active lineup + await db_ops.add_sba_lineup_player( + game_id=game_id, + team_id=team_id, + player_id=101, + position="CF", + batting_order=1, + is_starter=True + ) + await db_session.flush() + + # Get bench players (should be empty) + bench = await db_ops.get_bench_players(game_id, team_id) + + assert len(bench) == 0 + class TestDatabaseOperationsRollback: """Tests for database rollback operations (delete_plays_after, etc.)""" diff --git a/frontend-sba/components/Lineup/InlineSubstitutionPanel.vue b/frontend-sba/components/Lineup/InlineSubstitutionPanel.vue new file mode 100644 index 0000000..6421638 --- /dev/null +++ b/frontend-sba/components/Lineup/InlineSubstitutionPanel.vue @@ -0,0 +1,344 @@ + + + diff --git a/frontend-sba/components/Lineup/LineupSlotRow.vue b/frontend-sba/components/Lineup/LineupSlotRow.vue new file mode 100644 index 0000000..08f3b9a --- /dev/null +++ b/frontend-sba/components/Lineup/LineupSlotRow.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend-sba/components/Lineup/PositionSelector.vue b/frontend-sba/components/Lineup/PositionSelector.vue new file mode 100644 index 0000000..d97f17b --- /dev/null +++ b/frontend-sba/components/Lineup/PositionSelector.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend-sba/components/Lineup/UnifiedLineupTab.vue b/frontend-sba/components/Lineup/UnifiedLineupTab.vue new file mode 100644 index 0000000..c7e996a --- /dev/null +++ b/frontend-sba/components/Lineup/UnifiedLineupTab.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend-sba/store/game.ts b/frontend-sba/store/game.ts index 4003106..e1add09 100644 --- a/frontend-sba/store/game.ts +++ b/frontend-sba/store/game.ts @@ -15,6 +15,7 @@ import type { OffensiveDecision, RollData, Lineup, + BenchPlayer, } from '~/types' export const useGameStore = defineStore('game', () => { @@ -25,6 +26,8 @@ export const useGameStore = defineStore('game', () => { const gameState = ref(null) const homeLineup = ref([]) const awayLineup = ref([]) + const homeBench = ref([]) + const awayBench = ref([]) const playHistory = ref([]) const currentDecisionPrompt = ref(null) const pendingRoll = ref(null) @@ -186,6 +189,19 @@ export const useGameStore = defineStore('game', () => { } } + /** + * Set bench players for a specific team + * Bench players come from RosterLink (players not in active lineup) + * with is_pitcher/is_batter computed properties for UI filtering + */ + function setBench(teamId: number, bench: BenchPlayer[]) { + if (teamId === gameState.value?.home_team_id) { + homeBench.value = bench + } else if (teamId === gameState.value?.away_team_id) { + awayBench.value = bench + } + } + /** * Set play history (replaces entire array - used for initial sync) * O(1) operation - no deduplication needed @@ -323,6 +339,8 @@ export const useGameStore = defineStore('game', () => { gameState.value = null homeLineup.value = [] awayLineup.value = [] + homeBench.value = [] + awayBench.value = [] playHistory.value = [] currentDecisionPrompt.value = null pendingRoll.value = null @@ -373,6 +391,8 @@ export const useGameStore = defineStore('game', () => { gameState: readonly(gameState), homeLineup: readonly(homeLineup), awayLineup: readonly(awayLineup), + homeBench: readonly(homeBench), + awayBench: readonly(awayBench), playHistory: readonly(playHistory), currentDecisionPrompt: readonly(currentDecisionPrompt), pendingRoll: readonly(pendingRoll), @@ -421,6 +441,7 @@ export const useGameStore = defineStore('game', () => { updateGameState, setLineups, updateLineup, + setBench, setPlayHistory, addPlayToHistory, setDecisionPrompt, diff --git a/frontend-sba/types/index.ts b/frontend-sba/types/index.ts index 3ecb6ec..43b61a2 100644 --- a/frontend-sba/types/index.ts +++ b/frontend-sba/types/index.ts @@ -37,6 +37,7 @@ export type { SbaPlayer, Lineup, TeamLineup, + BenchPlayer, LineupDataResponse, SubstitutionType, SubstitutionRequest, diff --git a/frontend-sba/types/player.ts b/frontend-sba/types/player.ts index 43a04c0..680c48c 100644 --- a/frontend-sba/types/player.ts +++ b/frontend-sba/types/player.ts @@ -83,6 +83,33 @@ export interface TeamLineup { players: Lineup[] } +/** + * Bench player from RosterLink (for substitutions) + * Backend: SbaRosterLinkData with computed is_pitcher/is_batter + * + * This is distinct from Lineup - it represents roster players + * not currently in the active lineup. + */ +export interface BenchPlayer { + roster_id: number + player_id: number + player_positions: string[] // Natural positions (e.g., ["SS", "2B"]) + is_pitcher: boolean // True if player has pitching positions + is_batter: boolean // True if player has batting positions + + // Player data (from SBA API) + player: { + id: number + name: string + image: string + headshot: string + // Legacy position fields for backwards compatibility + pos_1: string | null + pos_2: string | null + pos_3: string | null + } +} + /** * Lineup data response from server */