CLAUDE: Add game page tabs with lineup persistence and per-team submission
- Refactor game page into tab container with Game, Lineups, Stats tabs - Extract GamePlay, LineupBuilder, GameStats components from page - Add manager-aware default tab logic (pending + manager → Lineups tab) - Implement per-team lineup submission (each team submits independently) - Add lineup-status endpoint with actual lineup data for recovery - Fix lineup persistence across tab switches and page refreshes - Query database directly for pending games (avoids GameState validation) - Add userTeamIds to auth store for manager detection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5562d8de36
commit
1f5e290d8b
@ -258,19 +258,39 @@ async def get_game(game_id: str):
|
|||||||
|
|
||||||
logger.info(f"Fetching game details for {game_id}")
|
logger.info(f"Fetching game details for {game_id}")
|
||||||
|
|
||||||
# Get game from state manager
|
# Try to get game from state manager first (in-memory)
|
||||||
if game_uuid not in state_manager._states:
|
state = state_manager.get_state(game_uuid)
|
||||||
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
if state:
|
||||||
|
return {
|
||||||
|
"game_id": game_id,
|
||||||
|
"status": state.status,
|
||||||
|
"home_team_id": state.home_team_id,
|
||||||
|
"away_team_id": state.away_team_id,
|
||||||
|
"league_id": state.league_id,
|
||||||
|
}
|
||||||
|
|
||||||
state = state_manager._states[game_uuid]
|
# Fallback: Load from database if not in memory
|
||||||
|
logger.info(f"Game {game_id} not in memory, loading from database")
|
||||||
|
from app.database.session import AsyncSessionLocal
|
||||||
|
from app.models.db_models import Game
|
||||||
|
|
||||||
return {
|
async with AsyncSessionLocal() as session:
|
||||||
"game_id": game_id,
|
from sqlalchemy import select
|
||||||
"status": state.status,
|
result = await session.execute(
|
||||||
"home_team_id": state.home_team_id,
|
select(Game).where(Game.id == game_uuid)
|
||||||
"away_team_id": state.away_team_id,
|
)
|
||||||
"league_id": state.league_id,
|
game = result.scalar_one_or_none()
|
||||||
}
|
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"game_id": game_id,
|
||||||
|
"status": game.status,
|
||||||
|
"home_team_id": game.home_team_id,
|
||||||
|
"away_team_id": game.away_team_id,
|
||||||
|
"league_id": game.league_id,
|
||||||
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@ -628,31 +648,18 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
|||||||
|
|
||||||
logger.info(f"Submitting lineups for game {game_id}")
|
logger.info(f"Submitting lineups for game {game_id}")
|
||||||
|
|
||||||
# Get game state from memory or load basic info from database
|
# Get game state from memory or recover from database
|
||||||
state = state_manager.get_state(game_uuid)
|
state = state_manager.get_state(game_uuid)
|
||||||
if not state:
|
if not state:
|
||||||
logger.info(f"Game {game_id} not in memory, loading from database")
|
logger.info(f"Game {game_id} not in memory, recovering from database")
|
||||||
|
|
||||||
# Load basic game info from database
|
# Use recover_game to properly load game state
|
||||||
db_ops = DatabaseOperations()
|
state = await state_manager.recover_game(game_uuid)
|
||||||
game_data = await db_ops.load_game_state(game_uuid)
|
|
||||||
|
|
||||||
if not game_data:
|
if not state:
|
||||||
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||||
|
|
||||||
game_info = game_data['game']
|
logger.info(f"Recovered game {game_id} from database")
|
||||||
|
|
||||||
# Recreate game state in memory (without lineups - we're about to add them)
|
|
||||||
state = await state_manager.create_game(
|
|
||||||
game_id=game_uuid,
|
|
||||||
league_id=game_info['league_id'],
|
|
||||||
home_team_id=game_info['home_team_id'],
|
|
||||||
away_team_id=game_info['away_team_id'],
|
|
||||||
home_team_is_ai=game_info.get('home_team_is_ai', False),
|
|
||||||
away_team_is_ai=game_info.get('away_team_is_ai', False),
|
|
||||||
auto_mode=game_info.get('auto_mode', False)
|
|
||||||
)
|
|
||||||
logger.info(f"Recreated game {game_id} in memory from database")
|
|
||||||
|
|
||||||
# Process home team lineup
|
# Process home team lineup
|
||||||
home_count = 0
|
home_count = 0
|
||||||
@ -708,3 +715,331 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to submit lineups: {str(e)}"
|
status_code=500, detail=f"Failed to submit lineups: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitTeamLineupRequest(BaseModel):
|
||||||
|
"""Request model for submitting a single team's lineup"""
|
||||||
|
|
||||||
|
team_id: int = Field(..., description="Team ID submitting the lineup")
|
||||||
|
lineup: list[LineupPlayerRequest] = Field(
|
||||||
|
..., min_length=9, max_length=10, description="Team's starting lineup (9-10 players)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("lineup")
|
||||||
|
@classmethod
|
||||||
|
def validate_lineup(cls, v: list[LineupPlayerRequest]) -> list[LineupPlayerRequest]:
|
||||||
|
"""Validate lineup structure - same rules as SubmitLineupsRequest"""
|
||||||
|
lineup_size = len(v)
|
||||||
|
|
||||||
|
# Check batting orders
|
||||||
|
batters = [p for p in v if p.batting_order is not None]
|
||||||
|
batting_orders = [p.batting_order for p in batters]
|
||||||
|
|
||||||
|
if len(batting_orders) != 9:
|
||||||
|
raise ValueError(f"Must have exactly 9 batters with batting orders, got {len(batting_orders)}")
|
||||||
|
|
||||||
|
if set(batting_orders) != {1, 2, 3, 4, 5, 6, 7, 8, 9}:
|
||||||
|
raise ValueError("Batting orders must be exactly 1-9 with no duplicates")
|
||||||
|
|
||||||
|
# Check positions
|
||||||
|
positions = [p.position for p in v]
|
||||||
|
required_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"]
|
||||||
|
|
||||||
|
for req_pos in required_positions:
|
||||||
|
if req_pos not in positions:
|
||||||
|
raise ValueError(f"Missing required position: {req_pos}")
|
||||||
|
|
||||||
|
# For 10-player lineup, must have DH and pitcher must not bat
|
||||||
|
if lineup_size == 10:
|
||||||
|
if "DH" not in positions:
|
||||||
|
raise ValueError("10-player lineup must include DH position")
|
||||||
|
|
||||||
|
pitchers = [p for p in v if p.position == "P"]
|
||||||
|
if len(pitchers) != 1:
|
||||||
|
raise ValueError("Must have exactly 1 pitcher")
|
||||||
|
|
||||||
|
if pitchers[0].batting_order is not None:
|
||||||
|
raise ValueError("Pitcher cannot have batting order in DH lineup")
|
||||||
|
|
||||||
|
# For 9-player lineup, pitcher must bat (no DH)
|
||||||
|
elif lineup_size == 9:
|
||||||
|
if "DH" in positions:
|
||||||
|
raise ValueError("9-player lineup cannot include DH")
|
||||||
|
|
||||||
|
pitchers = [p for p in v if p.position == "P"]
|
||||||
|
if len(pitchers) != 1:
|
||||||
|
raise ValueError("Must have exactly 1 pitcher")
|
||||||
|
|
||||||
|
if pitchers[0].batting_order is None:
|
||||||
|
raise ValueError("Pitcher must have batting order when no DH")
|
||||||
|
|
||||||
|
# Check player uniqueness
|
||||||
|
player_ids = [p.player_id for p in v]
|
||||||
|
if len(set(player_ids)) != len(player_ids):
|
||||||
|
raise ValueError("Players cannot be duplicated in lineup")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitTeamLineupResponse(BaseModel):
|
||||||
|
"""Response model for single team lineup submission"""
|
||||||
|
|
||||||
|
game_id: str
|
||||||
|
team_id: int
|
||||||
|
message: str
|
||||||
|
lineup_count: int
|
||||||
|
game_ready: bool # True if both teams have submitted and game can start
|
||||||
|
game_started: bool # True if game was auto-started
|
||||||
|
|
||||||
|
|
||||||
|
class LineupPlayerInfo(BaseModel):
|
||||||
|
"""Individual player in a lineup"""
|
||||||
|
player_id: int
|
||||||
|
position: str
|
||||||
|
batting_order: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class LineupStatusResponse(BaseModel):
|
||||||
|
"""Response model for lineup status check"""
|
||||||
|
|
||||||
|
game_id: str
|
||||||
|
home_team_id: int
|
||||||
|
away_team_id: int
|
||||||
|
home_lineup_submitted: bool
|
||||||
|
away_lineup_submitted: bool
|
||||||
|
home_lineup_count: int
|
||||||
|
away_lineup_count: int
|
||||||
|
game_status: str
|
||||||
|
# Include actual lineup data for display
|
||||||
|
home_lineup: list[LineupPlayerInfo] = []
|
||||||
|
away_lineup: list[LineupPlayerInfo] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{game_id}/lineup-status", response_model=LineupStatusResponse)
|
||||||
|
async def get_lineup_status(game_id: str):
|
||||||
|
"""
|
||||||
|
Check lineup submission status for a game.
|
||||||
|
|
||||||
|
Returns whether each team has submitted their lineup.
|
||||||
|
Works for pending games (before game state is fully initialized).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate game_id format
|
||||||
|
try:
|
||||||
|
game_uuid = UUID(game_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid game_id format")
|
||||||
|
|
||||||
|
# First check in-memory state (if game is active)
|
||||||
|
state = state_manager.get_state(game_uuid)
|
||||||
|
if state:
|
||||||
|
# Game is in memory, use state_manager for lineup info
|
||||||
|
home_lineup = state_manager.get_lineup(game_uuid, state.home_team_id)
|
||||||
|
away_lineup = state_manager.get_lineup(game_uuid, state.away_team_id)
|
||||||
|
|
||||||
|
home_count = len(home_lineup.players) if home_lineup else 0
|
||||||
|
away_count = len(away_lineup.players) if away_lineup else 0
|
||||||
|
|
||||||
|
# Convert to response format (card_id is player_id for SBA)
|
||||||
|
home_lineup_data = [
|
||||||
|
LineupPlayerInfo(
|
||||||
|
player_id=p.card_id,
|
||||||
|
position=p.position,
|
||||||
|
batting_order=p.batting_order
|
||||||
|
)
|
||||||
|
for p in (home_lineup.players if home_lineup else [])
|
||||||
|
]
|
||||||
|
away_lineup_data = [
|
||||||
|
LineupPlayerInfo(
|
||||||
|
player_id=p.card_id,
|
||||||
|
position=p.position,
|
||||||
|
batting_order=p.batting_order
|
||||||
|
)
|
||||||
|
for p in (away_lineup.players if away_lineup else [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return LineupStatusResponse(
|
||||||
|
game_id=game_id,
|
||||||
|
home_team_id=state.home_team_id,
|
||||||
|
away_team_id=state.away_team_id,
|
||||||
|
home_lineup_submitted=home_count >= 9,
|
||||||
|
away_lineup_submitted=away_count >= 9,
|
||||||
|
home_lineup_count=home_count,
|
||||||
|
away_lineup_count=away_count,
|
||||||
|
game_status=state.status,
|
||||||
|
home_lineup=home_lineup_data,
|
||||||
|
away_lineup=away_lineup_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Game not in memory - query database directly
|
||||||
|
# This works for pending games where GameState can't be fully constructed
|
||||||
|
db_ops = DatabaseOperations()
|
||||||
|
game = await db_ops.get_game(game_uuid)
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||||
|
|
||||||
|
# Get lineup data from database
|
||||||
|
home_lineups = await db_ops.get_active_lineup(game_uuid, game.home_team_id)
|
||||||
|
away_lineups = await db_ops.get_active_lineup(game_uuid, game.away_team_id)
|
||||||
|
|
||||||
|
home_count = len(home_lineups)
|
||||||
|
away_count = len(away_lineups)
|
||||||
|
|
||||||
|
# Convert to response format
|
||||||
|
home_lineup_data = [
|
||||||
|
LineupPlayerInfo(
|
||||||
|
player_id=lineup.player_id,
|
||||||
|
position=lineup.position,
|
||||||
|
batting_order=lineup.batting_order
|
||||||
|
)
|
||||||
|
for lineup in home_lineups
|
||||||
|
if lineup.player_id is not None # SBA uses player_id
|
||||||
|
]
|
||||||
|
away_lineup_data = [
|
||||||
|
LineupPlayerInfo(
|
||||||
|
player_id=lineup.player_id,
|
||||||
|
position=lineup.position,
|
||||||
|
batting_order=lineup.batting_order
|
||||||
|
)
|
||||||
|
for lineup in away_lineups
|
||||||
|
if lineup.player_id is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
return LineupStatusResponse(
|
||||||
|
game_id=game_id,
|
||||||
|
home_team_id=game.home_team_id,
|
||||||
|
away_team_id=game.away_team_id,
|
||||||
|
home_lineup_submitted=home_count >= 9,
|
||||||
|
away_lineup_submitted=away_count >= 9,
|
||||||
|
home_lineup_count=home_count,
|
||||||
|
away_lineup_count=away_count,
|
||||||
|
game_status=game.status,
|
||||||
|
home_lineup=home_lineup_data,
|
||||||
|
away_lineup=away_lineup_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to get lineup status for game {game_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get lineup status: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{game_id}/lineup", response_model=SubmitTeamLineupResponse)
|
||||||
|
async def submit_team_lineup(game_id: str, request: SubmitTeamLineupRequest):
|
||||||
|
"""
|
||||||
|
Submit lineup for a single team.
|
||||||
|
|
||||||
|
Accepts lineup for one team at a time. Game auto-starts when both teams
|
||||||
|
have submitted their lineups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_id: Game identifier
|
||||||
|
request: Team ID and lineup data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation with lineup count and game status
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
400: Invalid lineup structure, team not in game, or lineup already submitted
|
||||||
|
404: Game not found
|
||||||
|
500: Database or API error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate game_id format
|
||||||
|
try:
|
||||||
|
game_uuid = UUID(game_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid game_id format")
|
||||||
|
|
||||||
|
logger.info(f"Submitting lineup for team {request.team_id} in game {game_id}")
|
||||||
|
|
||||||
|
# Get game state from memory or recover from database
|
||||||
|
state = state_manager.get_state(game_uuid)
|
||||||
|
if not state:
|
||||||
|
logger.info(f"Game {game_id} not in memory, recovering from database")
|
||||||
|
|
||||||
|
# Use recover_game to properly load game state AND existing lineups
|
||||||
|
state = await state_manager.recover_game(game_uuid)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||||
|
|
||||||
|
logger.info(f"Recovered game {game_id} from database")
|
||||||
|
|
||||||
|
# Validate team is part of this game
|
||||||
|
if request.team_id not in [state.home_team_id, state.away_team_id]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Team {request.team_id} is not part of game {game_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if lineup already submitted for this team
|
||||||
|
is_home = request.team_id == state.home_team_id
|
||||||
|
existing_lineup = state_manager.get_lineup(game_uuid, request.team_id)
|
||||||
|
|
||||||
|
if existing_lineup and len(existing_lineup.players) > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Lineup already submitted for team {request.team_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process lineup
|
||||||
|
player_count = 0
|
||||||
|
for player in request.lineup:
|
||||||
|
await lineup_service.add_sba_player_to_lineup(
|
||||||
|
game_id=game_uuid,
|
||||||
|
team_id=request.team_id,
|
||||||
|
player_id=player.player_id,
|
||||||
|
position=player.position,
|
||||||
|
batting_order=player.batting_order,
|
||||||
|
is_starter=True,
|
||||||
|
)
|
||||||
|
player_count += 1
|
||||||
|
|
||||||
|
logger.info(f"Added {player_count} players to team {request.team_id} lineup")
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
home_ready = home_lineup is not None and len(home_lineup.players) >= 9
|
||||||
|
away_ready = away_lineup is not None and len(away_lineup.players) >= 9
|
||||||
|
game_ready = home_ready and away_ready
|
||||||
|
|
||||||
|
game_started = False
|
||||||
|
if game_ready:
|
||||||
|
# Auto-start the game
|
||||||
|
from app.core.game_engine import game_engine
|
||||||
|
try:
|
||||||
|
await game_engine.start_game(game_uuid)
|
||||||
|
game_started = True
|
||||||
|
logger.info(f"Game {game_id} auto-started after both lineups submitted")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to auto-start game {game_id}: {e}")
|
||||||
|
|
||||||
|
team_type = "home" if is_home else "away"
|
||||||
|
other_team = "away" if is_home else "home"
|
||||||
|
|
||||||
|
if game_started:
|
||||||
|
message = f"{team_type.capitalize()} lineup submitted. Game started!"
|
||||||
|
elif game_ready:
|
||||||
|
message = f"{team_type.capitalize()} lineup submitted. Both teams ready."
|
||||||
|
else:
|
||||||
|
message = f"{team_type.capitalize()} lineup submitted. Waiting for {other_team} team."
|
||||||
|
|
||||||
|
return SubmitTeamLineupResponse(
|
||||||
|
game_id=game_id,
|
||||||
|
team_id=request.team_id,
|
||||||
|
message=message,
|
||||||
|
lineup_count=player_count,
|
||||||
|
game_ready=game_ready,
|
||||||
|
game_started=game_started,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to submit lineup for team {request.team_id} in game {game_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to submit lineup: {str(e)}"
|
||||||
|
)
|
||||||
|
|||||||
817
frontend-sba/components/Game/GamePlay.vue
Normal file
817
frontend-sba/components/Game/GamePlay.vue
Normal file
@ -0,0 +1,817 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Main Game Container -->
|
||||||
|
<div class="container mx-auto px-4 py-6 lg:py-8">
|
||||||
|
<!-- Connection Error Banner (Permanently Failed) -->
|
||||||
|
<div
|
||||||
|
v-if="permanentlyFailed"
|
||||||
|
class="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-red-800">
|
||||||
|
Connection Failed
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-red-700 mt-1">
|
||||||
|
Unable to connect to the game server after multiple attempts. Please check your internet connection and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="manualRetry"
|
||||||
|
class="ml-4 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition flex-shrink-0"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Status Banner (Reconnecting) -->
|
||||||
|
<div
|
||||||
|
v-else-if="!isConnected"
|
||||||
|
class="mb-4 bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-yellow-700">
|
||||||
|
{{ connectionStatus === 'connecting' ? 'Connecting to game server...' : 'Disconnected from server. Attempting to reconnect...' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="forceReconnect"
|
||||||
|
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-700 bg-yellow-100 hover:bg-yellow-200 rounded-md transition"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Layout (Stacked) -->
|
||||||
|
<div class="lg:hidden space-y-6">
|
||||||
|
<!-- Current Situation -->
|
||||||
|
<CurrentSituation
|
||||||
|
:current-batter="gameState?.current_batter"
|
||||||
|
:current-pitcher="gameState?.current_pitcher"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Game Board -->
|
||||||
|
<GameBoard
|
||||||
|
:runners="runnersState"
|
||||||
|
:current-batter="gameState?.current_batter"
|
||||||
|
:current-pitcher="gameState?.current_pitcher"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Play-by-Play Feed -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-md">
|
||||||
|
<PlayByPlay
|
||||||
|
:plays="playHistory"
|
||||||
|
:limit="5"
|
||||||
|
:compact="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decision Panel (Phase F3) -->
|
||||||
|
<DecisionPanel
|
||||||
|
v-if="showDecisions"
|
||||||
|
:game-id="gameId"
|
||||||
|
:current-team="currentTeam"
|
||||||
|
:is-my-turn="isMyTurn"
|
||||||
|
:phase="decisionPhase"
|
||||||
|
:runners="runnersData"
|
||||||
|
:current-defensive-setup="pendingDefensiveSetup ?? undefined"
|
||||||
|
:current-offensive-decision="pendingOffensiveDecision ?? undefined"
|
||||||
|
:current-steal-attempts="pendingStealAttempts"
|
||||||
|
:decision-history="decisionHistory"
|
||||||
|
@defensive-submit="handleDefensiveSubmit"
|
||||||
|
@offensive-submit="handleOffensiveSubmit"
|
||||||
|
@steal-attempts-submit="handleStealAttemptsSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Gameplay Panel (Phase F4) -->
|
||||||
|
<GameplayPanel
|
||||||
|
v-if="showGameplay"
|
||||||
|
:game-id="gameId"
|
||||||
|
:is-my-turn="isMyTurn"
|
||||||
|
:can-roll-dice="canRollDice"
|
||||||
|
:pending-roll="pendingRoll"
|
||||||
|
:last-play-result="lastPlayResult"
|
||||||
|
:can-submit-outcome="canSubmitOutcome"
|
||||||
|
:outs="gameState?.outs ?? 0"
|
||||||
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
|
@roll-dice="handleRollDice"
|
||||||
|
@submit-outcome="handleSubmitOutcome"
|
||||||
|
@dismiss-result="handleDismissResult"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Layout (Grid) -->
|
||||||
|
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Left Column: Game State -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- Current Situation -->
|
||||||
|
<CurrentSituation
|
||||||
|
:current-batter="gameState?.current_batter"
|
||||||
|
:current-pitcher="gameState?.current_pitcher"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Game Board -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
||||||
|
<GameBoard
|
||||||
|
:runners="runnersState"
|
||||||
|
:current-batter="gameState?.current_batter"
|
||||||
|
:current-pitcher="gameState?.current_pitcher"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decision Panel (Phase F3) -->
|
||||||
|
<DecisionPanel
|
||||||
|
v-if="showDecisions"
|
||||||
|
:game-id="gameId"
|
||||||
|
:current-team="currentTeam"
|
||||||
|
:is-my-turn="isMyTurn"
|
||||||
|
:phase="decisionPhase"
|
||||||
|
:runners="runnersData"
|
||||||
|
:current-defensive-setup="pendingDefensiveSetup ?? undefined"
|
||||||
|
:current-offensive-decision="pendingOffensiveDecision ?? undefined"
|
||||||
|
:current-steal-attempts="pendingStealAttempts"
|
||||||
|
:decision-history="decisionHistory"
|
||||||
|
@defensive-submit="handleDefensiveSubmit"
|
||||||
|
@offensive-submit="handleOffensiveSubmit"
|
||||||
|
@steal-attempts-submit="handleStealAttemptsSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Gameplay Panel (Phase F4) -->
|
||||||
|
<GameplayPanel
|
||||||
|
v-if="showGameplay"
|
||||||
|
:game-id="gameId"
|
||||||
|
:is-my-turn="isMyTurn"
|
||||||
|
:can-roll-dice="canRollDice"
|
||||||
|
:pending-roll="pendingRoll"
|
||||||
|
:last-play-result="lastPlayResult"
|
||||||
|
:can-submit-outcome="canSubmitOutcome"
|
||||||
|
:outs="gameState?.outs ?? 0"
|
||||||
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
||||||
|
@roll-dice="handleRollDice"
|
||||||
|
@submit-outcome="handleSubmitOutcome"
|
||||||
|
@dismiss-result="handleDismissResult"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Play-by-Play -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div
|
||||||
|
class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg sticky top-36"
|
||||||
|
>
|
||||||
|
<PlayByPlay
|
||||||
|
:plays="playHistory"
|
||||||
|
:scrollable="true"
|
||||||
|
:max-height="600"
|
||||||
|
:show-filters="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-2xl text-center max-w-sm mx-4">
|
||||||
|
<!-- Show spinner only if actively connecting -->
|
||||||
|
<div v-if="isConnecting" class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
|
||||||
|
|
||||||
|
<!-- Show error state if not connecting -->
|
||||||
|
<div v-else class="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-900 dark:text-white font-semibold">
|
||||||
|
{{ isConnecting ? 'Connecting to game...' : permanentlyFailed ? 'Connection Failed' : 'Reconnecting...' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="permanentlyFailed" class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Unable to reach server after multiple attempts
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Status info -->
|
||||||
|
<div class="mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-left">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span :class="authStore.isAuthenticated ? 'text-green-600' : authStore.isLoading ? 'text-yellow-600' : 'text-red-600'">●</span>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">Auth: {{ authStore.isAuthenticated ? 'OK' : authStore.isLoading ? 'Checking...' : 'Failed' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm mt-1">
|
||||||
|
<span :class="isConnected ? 'text-green-600' : isConnecting ? 'text-yellow-600' : permanentlyFailed ? 'text-red-600' : 'text-orange-500'">●</span>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">WebSocket: {{ isConnected ? 'Connected' : isConnecting ? 'Connecting...' : permanentlyFailed ? 'Failed' : 'Reconnecting...' }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="connectionError" class="text-xs text-red-600 mt-2">{{ connectionError }}</p>
|
||||||
|
<!-- Debug info -->
|
||||||
|
<div class="mt-2 text-xs text-gray-500 border-t pt-2">
|
||||||
|
<p>WS URL: {{ wsDebugUrl }}</p>
|
||||||
|
<p>Socket exists: {{ socketExists }}</p>
|
||||||
|
<p class="mt-1 font-mono text-[10px] max-h-24 overflow-y-auto">{{ debugLog }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
@click="permanentlyFailed ? manualRetry() : forceReconnect()"
|
||||||
|
:class="permanentlyFailed ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'"
|
||||||
|
class="w-full px-4 py-2 text-sm font-medium text-white rounded-lg transition"
|
||||||
|
>
|
||||||
|
{{ permanentlyFailed ? 'Try Again' : 'Retry Connection' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!authStore.isAuthenticated"
|
||||||
|
@click="navigateTo('/auth/login?return_url=' + encodeURIComponent(currentPath))"
|
||||||
|
class="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded-lg transition"
|
||||||
|
>
|
||||||
|
Re-Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="isLoading = false"
|
||||||
|
class="w-full px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||||
|
>
|
||||||
|
Dismiss (view page anyway)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Not Started State -->
|
||||||
|
<div
|
||||||
|
v-if="gameState && gameState.status === 'pending'"
|
||||||
|
class="mt-6 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-700 rounded-xl p-8 text-center"
|
||||||
|
>
|
||||||
|
<div class="w-20 h-20 mx-auto mb-4 bg-blue-100 dark:bg-blue-800 rounded-full flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Game Starting Soon</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
Waiting for all players to join. The game will begin once everyone is ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Ended State -->
|
||||||
|
<div
|
||||||
|
v-if="gameState && gameState.status === 'completed'"
|
||||||
|
class="mt-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-700 rounded-xl p-8 text-center"
|
||||||
|
>
|
||||||
|
<div class="w-20 h-20 mx-auto mb-4 bg-green-100 dark:bg-green-800 rounded-full flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Game Complete!</h3>
|
||||||
|
<p class="text-xl text-gray-700 dark:text-gray-300 mb-4">
|
||||||
|
Final Score: {{ gameState.away_score }} - {{ gameState.home_score }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="px-6 py-3 bg-primary hover:bg-blue-700 text-white rounded-lg font-semibold transition shadow-md"
|
||||||
|
@click="navigateTo('/')"
|
||||||
|
>
|
||||||
|
Back to Games
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Substitution Panel Modal (Phase F5) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showSubstitutions"
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
@click.self="handleSubstitutionCancel"
|
||||||
|
>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<SubstitutionPanel
|
||||||
|
v-if="myTeamId"
|
||||||
|
:game-id="gameId"
|
||||||
|
:team-id="myTeamId"
|
||||||
|
:current-lineup="currentLineup"
|
||||||
|
:bench-players="benchPlayers"
|
||||||
|
:current-pitcher="currentPitcher"
|
||||||
|
:current-batter="currentBatter"
|
||||||
|
@pinch-hitter="handlePinchHitter"
|
||||||
|
@defensive-replacement="handleDefensiveReplacement"
|
||||||
|
@pitching-change="handlePitchingChange"
|
||||||
|
@cancel="handleSubstitutionCancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Floating Action Buttons -->
|
||||||
|
<div class="fixed bottom-6 right-6 flex flex-col gap-3 z-40">
|
||||||
|
<!-- Undo Last Play Button -->
|
||||||
|
<button
|
||||||
|
v-if="canUndo"
|
||||||
|
class="w-14 h-14 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||||
|
aria-label="Undo Last Play"
|
||||||
|
title="Undo Last Play"
|
||||||
|
@click="handleUndoLastPlay"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Substitutions Button -->
|
||||||
|
<button
|
||||||
|
v-if="canMakeSubstitutions"
|
||||||
|
class="w-16 h-16 bg-primary hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
||||||
|
aria-label="Open Substitutions"
|
||||||
|
@click="showSubstitutions = true"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGameStore } from '~/store/game'
|
||||||
|
import { useAuthStore } from '~/store/auth'
|
||||||
|
import { useUiStore } from '~/store/ui'
|
||||||
|
import { useWebSocket } from '~/composables/useWebSocket'
|
||||||
|
import { useGameActions } from '~/composables/useGameActions'
|
||||||
|
import GameBoard from '~/components/Game/GameBoard.vue'
|
||||||
|
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
|
||||||
|
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
||||||
|
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
|
||||||
|
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
||||||
|
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.vue'
|
||||||
|
import type { DefensiveDecision, OffensiveDecision, PlayOutcome } from '~/types/game'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
gameId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
// Current path for login redirect
|
||||||
|
const currentPath = computed(() => route.fullPath)
|
||||||
|
|
||||||
|
// WebSocket connection
|
||||||
|
const { socket, isConnected, isConnecting, connectionError, permanentlyFailed, connect, forceReconnect, manualRetry } = useWebSocket()
|
||||||
|
|
||||||
|
// Debug info for troubleshooting Safari WebSocket issues
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const wsDebugUrl = computed(() => config.public.wsUrl || 'not set')
|
||||||
|
const socketExists = computed(() => socket.value ? 'yes' : 'no')
|
||||||
|
const debugLog = ref('Loading...')
|
||||||
|
|
||||||
|
// Pass the gameId prop to useGameActions
|
||||||
|
const actions = useGameActions(props.gameId)
|
||||||
|
|
||||||
|
// Destructure undoLastPlay for the undo button
|
||||||
|
const { undoLastPlay } = actions
|
||||||
|
|
||||||
|
// Game state from store
|
||||||
|
const gameState = computed(() => {
|
||||||
|
const state = gameStore.gameState
|
||||||
|
if (state) {
|
||||||
|
const batterInfo = state.current_batter
|
||||||
|
? `lineup_id=${state.current_batter.lineup_id}, batting_order=${state.current_batter.batting_order}`
|
||||||
|
: 'None'
|
||||||
|
console.log('[GamePlay] gameState computed - current_batter:', batterInfo)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
const playHistory = computed(() => gameStore.playHistory)
|
||||||
|
const canRollDice = computed(() => gameStore.canRollDice)
|
||||||
|
const canSubmitOutcome = computed(() => gameStore.canSubmitOutcome)
|
||||||
|
const pendingDefensiveSetup = computed(() => gameStore.pendingDefensiveSetup)
|
||||||
|
const pendingOffensiveDecision = computed(() => gameStore.pendingOffensiveDecision)
|
||||||
|
const pendingStealAttempts = computed(() => gameStore.pendingStealAttempts)
|
||||||
|
const decisionHistory = computed(() => gameStore.decisionHistory)
|
||||||
|
const needsDefensiveDecision = computed(() => gameStore.needsDefensiveDecision)
|
||||||
|
const needsOffensiveDecision = computed(() => gameStore.needsOffensiveDecision)
|
||||||
|
const basesEmpty = computed(() => gameStore.basesEmpty)
|
||||||
|
const pendingRoll = computed(() => gameStore.pendingRoll)
|
||||||
|
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
||||||
|
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
||||||
|
|
||||||
|
// Local UI state
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
||||||
|
const showSubstitutions = ref(false)
|
||||||
|
|
||||||
|
// Determine which team the user controls
|
||||||
|
// For demo/testing: user controls whichever team needs to act
|
||||||
|
const myTeamId = computed(() => {
|
||||||
|
if (!gameState.value) return null
|
||||||
|
|
||||||
|
// Return the team that currently needs to make a decision
|
||||||
|
if (gameState.value.half === 'top') {
|
||||||
|
// Top: away bats, home fields
|
||||||
|
return gameState.value.decision_phase === 'awaiting_defensive'
|
||||||
|
? gameState.value.home_team_id
|
||||||
|
: gameState.value.away_team_id
|
||||||
|
} else {
|
||||||
|
// Bottom: home bats, away fields
|
||||||
|
return gameState.value.decision_phase === 'awaiting_defensive'
|
||||||
|
? gameState.value.away_team_id
|
||||||
|
: gameState.value.home_team_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed helpers
|
||||||
|
const runnersState = computed(() => {
|
||||||
|
if (!gameState.value) {
|
||||||
|
return { first: false, second: false, third: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
first: gameState.value.on_first !== null,
|
||||||
|
second: gameState.value.on_second !== null,
|
||||||
|
third: gameState.value.on_third !== null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const runnersData = computed(() => {
|
||||||
|
return {
|
||||||
|
first: gameState.value?.on_first ?? null,
|
||||||
|
second: gameState.value?.on_second ?? null,
|
||||||
|
third: gameState.value?.on_third ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTeam = computed(() => {
|
||||||
|
return gameState.value?.half === 'top' ? 'away' : 'home'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine if it's the current user's turn to act
|
||||||
|
const isMyTurn = computed(() => {
|
||||||
|
if (!myTeamId.value || !gameState.value) return false
|
||||||
|
|
||||||
|
// During decision phases, check which team needs to decide
|
||||||
|
if (needsDefensiveDecision.value) {
|
||||||
|
// Fielding team makes defensive decisions
|
||||||
|
const fieldingTeamId = gameState.value.half === 'top'
|
||||||
|
? gameState.value.home_team_id
|
||||||
|
: gameState.value.away_team_id
|
||||||
|
return myTeamId.value === fieldingTeamId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsOffensiveDecision.value) {
|
||||||
|
// Batting team makes offensive decisions
|
||||||
|
const battingTeamId = gameState.value.half === 'top'
|
||||||
|
? gameState.value.away_team_id
|
||||||
|
: gameState.value.home_team_id
|
||||||
|
return myTeamId.value === battingTeamId
|
||||||
|
}
|
||||||
|
|
||||||
|
// During resolution phase (dice rolling, outcome submission),
|
||||||
|
// the BATTING team has control (they read their card)
|
||||||
|
const battingTeamId = gameState.value.half === 'top'
|
||||||
|
? gameState.value.away_team_id
|
||||||
|
: gameState.value.home_team_id
|
||||||
|
return myTeamId.value === battingTeamId
|
||||||
|
})
|
||||||
|
|
||||||
|
const decisionPhase = computed(() => {
|
||||||
|
if (needsDefensiveDecision.value) return 'defensive'
|
||||||
|
if (needsOffensiveDecision.value) return 'offensive'
|
||||||
|
return 'idle'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Phase F6: Conditional panel rendering
|
||||||
|
const showDecisions = computed(() => {
|
||||||
|
// Don't show decision panels if there's a result pending dismissal
|
||||||
|
if (lastPlayResult.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gameState.value?.status === 'active' &&
|
||||||
|
isMyTurn.value &&
|
||||||
|
(needsDefensiveDecision.value || needsOffensiveDecision.value)
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('[GamePlay] Panel visibility check:', {
|
||||||
|
gameStatus: gameState.value?.status,
|
||||||
|
isMyTurn: isMyTurn.value,
|
||||||
|
needsDefensiveDecision: needsDefensiveDecision.value,
|
||||||
|
needsOffensiveDecision: needsOffensiveDecision.value,
|
||||||
|
decision_phase: gameState.value?.decision_phase,
|
||||||
|
showDecisions: result,
|
||||||
|
currentDecisionPrompt: currentDecisionPrompt.value,
|
||||||
|
hasLastPlayResult: !!lastPlayResult.value
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const showGameplay = computed(() => {
|
||||||
|
// Show gameplay panel if there's a result to display OR if we're in the resolution phase
|
||||||
|
if (lastPlayResult.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameState.value?.status === 'active' &&
|
||||||
|
isMyTurn.value &&
|
||||||
|
!needsDefensiveDecision.value &&
|
||||||
|
!needsOffensiveDecision.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canMakeSubstitutions = computed(() => {
|
||||||
|
return gameState.value?.status === 'active' && isMyTurn.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canUndo = computed(() => {
|
||||||
|
// Can only undo if game is active and there are plays to undo
|
||||||
|
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lineup helpers for substitutions
|
||||||
|
const currentLineup = computed(() => {
|
||||||
|
if (!myTeamId.value) return []
|
||||||
|
return myTeamId.value === gameState.value?.home_team_id
|
||||||
|
? gameStore.homeLineup.filter(l => l.is_active)
|
||||||
|
: gameStore.awayLineup.filter(l => l.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
|
const benchPlayers = computed(() => {
|
||||||
|
if (!myTeamId.value) return []
|
||||||
|
return myTeamId.value === gameState.value?.home_team_id
|
||||||
|
? gameStore.homeLineup.filter(l => !l.is_active)
|
||||||
|
: gameStore.awayLineup.filter(l => !l.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentBatter = computed(() => {
|
||||||
|
const batterState = gameState.value?.current_batter
|
||||||
|
if (!batterState) return null
|
||||||
|
return gameStore.findPlayerInLineup(batterState.lineup_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPitcher = computed(() => {
|
||||||
|
const pitcherState = gameState.value?.current_pitcher
|
||||||
|
if (!pitcherState) return null
|
||||||
|
return gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods - Gameplay (Phase F4)
|
||||||
|
const handleRollDice = async () => {
|
||||||
|
console.log('[GamePlay] Rolling dice')
|
||||||
|
try {
|
||||||
|
await actions.rollDice()
|
||||||
|
// The dice_rolled event will update pendingRoll via WebSocket
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to roll dice:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitOutcome = async (data: { outcome: PlayOutcome; hitLocation?: string }) => {
|
||||||
|
console.log('[GamePlay] Submitting outcome:', data)
|
||||||
|
try {
|
||||||
|
await actions.submitManualOutcome(
|
||||||
|
data.outcome,
|
||||||
|
data.hitLocation
|
||||||
|
)
|
||||||
|
// Pending roll will be cleared by backend after successful submission
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to submit outcome:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismissResult = () => {
|
||||||
|
console.log('[GamePlay] Dismissing result')
|
||||||
|
gameStore.clearLastPlayResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDefensiveSubmit = async (decision: DefensiveDecision) => {
|
||||||
|
console.log('[GamePlay] Submitting defensive decision:', decision)
|
||||||
|
try {
|
||||||
|
await actions.submitDefensiveDecision(decision)
|
||||||
|
gameStore.setPendingDefensiveSetup(decision)
|
||||||
|
gameStore.addDecisionToHistory('Defensive', `${decision.infield_depth} infield, ${decision.outfield_depth} outfield`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to submit defensive decision:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOffensiveSubmit = async (decision: Omit<OffensiveDecision, 'steal_attempts'>) => {
|
||||||
|
console.log('[GamePlay] Submitting offensive decision:', decision)
|
||||||
|
try {
|
||||||
|
// Combine with steal attempts
|
||||||
|
const fullDecision: OffensiveDecision = {
|
||||||
|
...decision,
|
||||||
|
steal_attempts: pendingStealAttempts.value,
|
||||||
|
}
|
||||||
|
await actions.submitOffensiveDecision(fullDecision)
|
||||||
|
gameStore.setPendingOffensiveDecision(decision)
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
swing_away: 'Swing Away',
|
||||||
|
steal: 'Steal',
|
||||||
|
check_jump: 'Check Jump',
|
||||||
|
hit_and_run: 'Hit & Run',
|
||||||
|
sac_bunt: 'Sac Bunt',
|
||||||
|
squeeze_bunt: 'Squeeze Bunt',
|
||||||
|
}
|
||||||
|
gameStore.addDecisionToHistory('Offensive', actionLabels[decision.action] || decision.action)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to submit offensive decision:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStealAttemptsSubmit = (attempts: number[]) => {
|
||||||
|
console.log('[GamePlay] Updating steal attempts:', attempts)
|
||||||
|
gameStore.setPendingStealAttempts(attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods - Substitutions (Phase F5)
|
||||||
|
const handlePinchHitter = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
||||||
|
console.log('[GamePlay] Submitting pinch hitter:', data)
|
||||||
|
try {
|
||||||
|
await actions.submitSubstitution(
|
||||||
|
'pinch_hitter',
|
||||||
|
data.playerOutLineupId,
|
||||||
|
data.playerInCardId,
|
||||||
|
data.teamId
|
||||||
|
)
|
||||||
|
showSubstitutions.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to submit pinch hitter:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDefensiveReplacement = async (data: { playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }) => {
|
||||||
|
console.log('[GamePlay] Submitting defensive replacement:', data)
|
||||||
|
try {
|
||||||
|
await actions.submitSubstitution(
|
||||||
|
'defensive_replacement',
|
||||||
|
data.playerOutLineupId,
|
||||||
|
data.playerInCardId,
|
||||||
|
data.teamId,
|
||||||
|
data.newPosition
|
||||||
|
)
|
||||||
|
showSubstitutions.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to submit defensive replacement:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePitchingChange = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
||||||
|
console.log('[GamePlay] Submitting pitching change:', data)
|
||||||
|
try {
|
||||||
|
await actions.submitSubstitution(
|
||||||
|
'pitching_change',
|
||||||
|
data.playerOutLineupId,
|
||||||
|
data.playerInCardId,
|
||||||
|
data.teamId
|
||||||
|
)
|
||||||
|
showSubstitutions.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GamePlay] Failed to submit pitching change:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubstitutionCancel = () => {
|
||||||
|
console.log('[GamePlay] Cancelling substitution')
|
||||||
|
showSubstitutions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo handler
|
||||||
|
const handleUndoLastPlay = () => {
|
||||||
|
console.log('[GamePlay] Undoing last play')
|
||||||
|
undoLastPlay(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
|
// Debug logging for Safari troubleshooting
|
||||||
|
debugLog.value = `Mounted at ${new Date().toLocaleTimeString()}\n`
|
||||||
|
debugLog.value += `isConnected: ${isConnected.value}, isConnecting: ${isConnecting.value}\n`
|
||||||
|
|
||||||
|
// Try to connect WebSocket immediately - cookies will be sent automatically
|
||||||
|
// Backend will authenticate via cookies and reject if invalid
|
||||||
|
if (!isConnected.value && !isConnecting.value) {
|
||||||
|
debugLog.value += 'Calling connect()...\n'
|
||||||
|
connect()
|
||||||
|
debugLog.value += 'connect() called\n'
|
||||||
|
} else {
|
||||||
|
debugLog.value += 'Skipped connect (already connected/connecting)\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check auth store (for display purposes)
|
||||||
|
authStore.checkAuth()
|
||||||
|
|
||||||
|
// Wait for connection, then join game
|
||||||
|
watch(isConnected, async (connected) => {
|
||||||
|
if (connected) {
|
||||||
|
connectionStatus.value = 'connected'
|
||||||
|
console.log('[GamePlay] Connected - Joining game as player')
|
||||||
|
|
||||||
|
// Join game room
|
||||||
|
await actions.joinGame('player')
|
||||||
|
|
||||||
|
// Request current game state
|
||||||
|
await actions.requestGameState()
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
} else {
|
||||||
|
connectionStatus.value = 'disconnected'
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Timeout fallback - if not connected after 5 seconds, stop loading
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isLoading.value) {
|
||||||
|
console.error('[GamePlay] Connection timeout - stopping loading state')
|
||||||
|
isLoading.value = false
|
||||||
|
connectionStatus.value = 'disconnected'
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for game state to load lineups
|
||||||
|
watch(gameState, (state, oldState) => {
|
||||||
|
if (state && state.home_team_id && state.away_team_id) {
|
||||||
|
const oldBatter = oldState?.current_batter
|
||||||
|
const newBatter = state?.current_batter
|
||||||
|
const oldBatterInfo = oldBatter
|
||||||
|
? `lineup_id=${oldBatter.lineup_id}, batting_order=${oldBatter.batting_order}`
|
||||||
|
: 'None'
|
||||||
|
const newBatterInfo = newBatter
|
||||||
|
? `lineup_id=${newBatter.lineup_id}, batting_order=${newBatter.batting_order}`
|
||||||
|
: 'None'
|
||||||
|
console.log('[GamePlay] gameState watch - current_batter:', oldBatterInfo, '->', newBatterInfo)
|
||||||
|
// Request lineup data for both teams to populate player names
|
||||||
|
console.log('[GamePlay] Game state received - requesting lineups for teams:', state.home_team_id, state.away_team_id)
|
||||||
|
actions.getLineup(state.home_team_id)
|
||||||
|
actions.getLineup(state.away_team_id)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Quality of Life: Auto-submit default decisions when bases are empty
|
||||||
|
watch([needsDefensiveDecision, needsOffensiveDecision, basesEmpty], ([defensive, offensive, empty]) => {
|
||||||
|
// Only auto-submit if it's the player's turn and bases are empty
|
||||||
|
if (!isMyTurn.value || !empty) return
|
||||||
|
|
||||||
|
// Auto-submit defensive decision with defaults
|
||||||
|
if (defensive && !pendingDefensiveSetup.value) {
|
||||||
|
const defaultDefense: DefensiveDecision = {
|
||||||
|
infield_depth: 'normal',
|
||||||
|
outfield_depth: 'normal',
|
||||||
|
hold_runners: []
|
||||||
|
}
|
||||||
|
console.log('[GamePlay] Bases empty - auto-submitting default defensive decision')
|
||||||
|
uiStore.showInfo('Bases empty - auto-submitting default defensive setup', 2000)
|
||||||
|
handleDefensiveSubmit(defaultDefense)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-submit offensive decision with swing away
|
||||||
|
if (offensive && !pendingOffensiveDecision.value) {
|
||||||
|
const defaultOffense = {
|
||||||
|
action: 'swing_away' as const
|
||||||
|
}
|
||||||
|
console.log('[GamePlay] Bases empty - auto-submitting default offensive decision')
|
||||||
|
uiStore.showInfo('Bases empty - auto-submitting swing away', 2000)
|
||||||
|
handleOffensiveSubmit(defaultOffense)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
console.log('[GamePlay] Unmounted - Leaving game')
|
||||||
|
|
||||||
|
// Leave game room
|
||||||
|
actions.leaveGame()
|
||||||
|
|
||||||
|
// Reset game store
|
||||||
|
gameStore.resetGame()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for connection errors
|
||||||
|
watch(connectionError, (error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('[GamePlay] Connection error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Additional styling if needed */
|
||||||
|
</style>
|
||||||
39
frontend-sba/components/Game/GameStats.vue
Normal file
39
frontend-sba/components/Game/GameStats.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* GameStats Component - Placeholder
|
||||||
|
*
|
||||||
|
* Will eventually display game statistics including:
|
||||||
|
* - Box score (batting/pitching stats)
|
||||||
|
* - Play-by-play details
|
||||||
|
* - Player performance breakdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
gameId: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-8 text-center">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 mx-auto"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-700 mb-2">Game Statistics</h3>
|
||||||
|
<p class="text-gray-500">Detailed statistics will be available here soon.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-4">
|
||||||
|
Box scores, batting stats, pitching stats, and play-by-play analysis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,18 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
|
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
|
||||||
import ActionButton from '~/components/UI/ActionButton.vue'
|
import ActionButton from '~/components/UI/ActionButton.vue'
|
||||||
|
|
||||||
// Use no layout - this page has its own complete UI
|
// Props
|
||||||
definePageMeta({ layout: false })
|
const props = defineProps<{
|
||||||
|
gameId: string
|
||||||
|
teamId?: number | null // Optional: if provided, user manages this team
|
||||||
|
}>()
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
// Game and team data
|
// Game and team data
|
||||||
const gameId = ref(route.params.id as string)
|
|
||||||
const homeTeamId = ref<number | null>(null)
|
const homeTeamId = ref<number | null>(null)
|
||||||
const awayTeamId = ref<number | null>(null)
|
const awayTeamId = ref<number | null>(null)
|
||||||
const season = ref(3)
|
const season = ref(3)
|
||||||
@ -36,6 +36,12 @@ const awayRoster = ref<SbaPlayer[]>([])
|
|||||||
const loadingRoster = ref(false)
|
const loadingRoster = ref(false)
|
||||||
const submittingLineups = ref(false)
|
const submittingLineups = ref(false)
|
||||||
|
|
||||||
|
// Per-team submission state
|
||||||
|
const homeSubmitted = ref(false)
|
||||||
|
const awaySubmitted = ref(false)
|
||||||
|
const submittingHome = ref(false)
|
||||||
|
const submittingAway = ref(false)
|
||||||
|
|
||||||
// Lineup state - 10 slots each (1-9 batting, 10 pitcher)
|
// Lineup state - 10 slots each (1-9 batting, 10 pitcher)
|
||||||
interface LineupSlot {
|
interface LineupSlot {
|
||||||
player: SbaPlayer | null
|
player: SbaPlayer | null
|
||||||
@ -95,14 +101,16 @@ const duplicatePositions = computed(() => {
|
|||||||
.map(([pos, _]) => pos)
|
.map(([pos, _]) => pos)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-team validation
|
||||||
|
const homeValidationErrors = computed(() => validateLineup(homeLineup.value, 'Home'))
|
||||||
|
const awayValidationErrors = computed(() => validateLineup(awayLineup.value, 'Away'))
|
||||||
|
|
||||||
const validationErrors = computed(() => {
|
const validationErrors = computed(() => {
|
||||||
const errors: string[] = []
|
// Show errors for active tab only
|
||||||
|
if (activeTab.value === 'home') {
|
||||||
// Check both lineups
|
return homeValidationErrors.value
|
||||||
const homeErrors = validateLineup(homeLineup.value, 'Home')
|
}
|
||||||
const awayErrors = validateLineup(awayLineup.value, 'Away')
|
return awayValidationErrors.value
|
||||||
|
|
||||||
return [...homeErrors, ...awayErrors]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
|
function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
|
||||||
@ -143,8 +151,28 @@ function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-team submit availability
|
||||||
|
const canSubmitHome = computed(() => homeValidationErrors.value.length === 0 && !homeSubmitted.value)
|
||||||
|
const canSubmitAway = computed(() => awayValidationErrors.value.length === 0 && !awaySubmitted.value)
|
||||||
|
|
||||||
|
// For active tab
|
||||||
|
const canSubmitActiveTeam = computed(() => {
|
||||||
|
if (activeTab.value === 'home') {
|
||||||
|
return canSubmitHome.value
|
||||||
|
}
|
||||||
|
return canSubmitAway.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isActiveTeamSubmitted = computed(() => {
|
||||||
|
return activeTab.value === 'home' ? homeSubmitted.value : awaySubmitted.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSubmittingActiveTeam = computed(() => {
|
||||||
|
return activeTab.value === 'home' ? submittingHome.value : submittingAway.value
|
||||||
|
})
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
return validationErrors.value.length === 0
|
return homeValidationErrors.value.length === 0 && awayValidationErrors.value.length === 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get player's available positions
|
// Get player's available positions
|
||||||
@ -287,7 +315,9 @@ function getPlayerFallbackInitial(player: SbaPlayer): string {
|
|||||||
// Fetch game data
|
// Fetch game data
|
||||||
async function fetchGameData() {
|
async function fetchGameData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${config.public.apiUrl}/api/games/${gameId.value}`)
|
const response = await fetch(`${config.public.apiUrl}/api/games/${props.gameId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
homeTeamId.value = data.home_team_id
|
homeTeamId.value = data.home_team_id
|
||||||
awayTeamId.value = data.away_team_id
|
awayTeamId.value = data.away_team_id
|
||||||
@ -300,7 +330,9 @@ async function fetchGameData() {
|
|||||||
async function fetchRoster(teamId: number) {
|
async function fetchRoster(teamId: number) {
|
||||||
try {
|
try {
|
||||||
loadingRoster.value = true
|
loadingRoster.value = true
|
||||||
const response = await fetch(`${config.public.apiUrl}/api/teams/${teamId}/roster?season=${season.value}`)
|
const response = await fetch(`${config.public.apiUrl}/api/teams/${teamId}/roster?season=${season.value}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return data.players as SbaPlayer[]
|
return data.players as SbaPlayer[]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -311,7 +343,85 @@ async function fetchRoster(teamId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit lineups
|
// Submit single team lineup
|
||||||
|
async function submitTeamLineup(team: 'home' | 'away') {
|
||||||
|
const teamId = team === 'home' ? homeTeamId.value : awayTeamId.value
|
||||||
|
const lineup = team === 'home' ? homeLineup.value : awayLineup.value
|
||||||
|
const isSubmitting = team === 'home' ? submittingHome : submittingAway
|
||||||
|
const submitted = team === 'home' ? homeSubmitted : awaySubmitted
|
||||||
|
const canSubmitTeam = team === 'home' ? canSubmitHome.value : canSubmitAway.value
|
||||||
|
|
||||||
|
if (!canSubmitTeam || isSubmitting.value || !teamId) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
const lineupRequest = lineup
|
||||||
|
.filter(s => s.player)
|
||||||
|
.map(s => ({
|
||||||
|
player_id: s.player!.id,
|
||||||
|
position: s.position!,
|
||||||
|
batting_order: s.battingOrder
|
||||||
|
}))
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
team_id: teamId,
|
||||||
|
lineup: lineupRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Submitting ${team} lineup:`, JSON.stringify(request, null, 2))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.public.apiUrl}/api/games/${props.gameId}/lineup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
console.error(`${team} lineup submission error:`, error)
|
||||||
|
|
||||||
|
// Handle Pydantic validation errors
|
||||||
|
if (error.detail && Array.isArray(error.detail)) {
|
||||||
|
const messages = error.detail.map((err: any) => {
|
||||||
|
if (err.loc) {
|
||||||
|
const location = err.loc.join(' -> ')
|
||||||
|
return `${location}: ${err.msg}`
|
||||||
|
}
|
||||||
|
return err.msg || JSON.stringify(err)
|
||||||
|
})
|
||||||
|
throw new Error(`Validation errors:\n${messages.join('\n')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
console.log(`${team} lineup submitted:`, result)
|
||||||
|
|
||||||
|
// Mark as submitted
|
||||||
|
submitted.value = true
|
||||||
|
|
||||||
|
// If game started, emit event
|
||||||
|
if (result.game_started) {
|
||||||
|
emit('lineups-submitted', result)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to submit ${team} lineup:`, error)
|
||||||
|
alert(error instanceof Error ? error.message : `Failed to submit ${team} lineup`)
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit active team's lineup
|
||||||
|
async function submitActiveTeamLineup() {
|
||||||
|
await submitTeamLineup(activeTab.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit lineups (legacy - both teams at once)
|
||||||
async function submitLineups() {
|
async function submitLineups() {
|
||||||
if (!canSubmit.value || submittingLineups.value) return
|
if (!canSubmit.value || submittingLineups.value) return
|
||||||
|
|
||||||
@ -342,10 +452,11 @@ async function submitLineups() {
|
|||||||
console.log('Submitting lineup request:', JSON.stringify(request, null, 2))
|
console.log('Submitting lineup request:', JSON.stringify(request, null, 2))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${config.public.apiUrl}/api/games/${gameId.value}/lineups`, {
|
const response = await fetch(`${config.public.apiUrl}/api/games/${props.gameId}/lineups`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(request)
|
body: JSON.stringify(request),
|
||||||
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -356,7 +467,7 @@ async function submitLineups() {
|
|||||||
if (error.detail && Array.isArray(error.detail)) {
|
if (error.detail && Array.isArray(error.detail)) {
|
||||||
const messages = error.detail.map((err: any) => {
|
const messages = error.detail.map((err: any) => {
|
||||||
if (err.loc) {
|
if (err.loc) {
|
||||||
const location = err.loc.join(' → ')
|
const location = err.loc.join(' -> ')
|
||||||
return `${location}: ${err.msg}`
|
return `${location}: ${err.msg}`
|
||||||
}
|
}
|
||||||
return err.msg || JSON.stringify(err)
|
return err.msg || JSON.stringify(err)
|
||||||
@ -370,8 +481,8 @@ async function submitLineups() {
|
|||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
console.log('Lineups submitted:', result)
|
console.log('Lineups submitted:', result)
|
||||||
|
|
||||||
// Redirect to game page
|
// Emit event instead of navigating (parent component handles navigation)
|
||||||
router.push(`/games/${gameId.value}`)
|
emit('lineups-submitted', result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit lineups:', error)
|
console.error('Failed to submit lineups:', error)
|
||||||
alert(error instanceof Error ? error.message : 'Failed to submit lineups')
|
alert(error instanceof Error ? error.message : 'Failed to submit lineups')
|
||||||
@ -380,38 +491,125 @@ async function submitLineups() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit events
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'lineups-submitted': [result: any]
|
||||||
|
}>()
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
|
// Stored lineup data from backend (populated before rosters load)
|
||||||
|
interface LineupPlayerInfo {
|
||||||
|
player_id: number
|
||||||
|
position: string
|
||||||
|
batting_order: number | null
|
||||||
|
}
|
||||||
|
const pendingHomeLineup = ref<LineupPlayerInfo[]>([])
|
||||||
|
const pendingAwayLineup = ref<LineupPlayerInfo[]>([])
|
||||||
|
|
||||||
|
// Check lineup status from backend
|
||||||
|
async function checkLineupStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.public.apiUrl}/api/games/${props.gameId}/lineup-status`, {
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const status = await response.json()
|
||||||
|
console.log('[LineupBuilder] Lineup status:', status)
|
||||||
|
|
||||||
|
// Update submitted state based on backend
|
||||||
|
homeSubmitted.value = status.home_lineup_submitted
|
||||||
|
awaySubmitted.value = status.away_lineup_submitted
|
||||||
|
|
||||||
|
// Store team IDs if not already set
|
||||||
|
if (!homeTeamId.value) homeTeamId.value = status.home_team_id
|
||||||
|
if (!awayTeamId.value) awayTeamId.value = status.away_team_id
|
||||||
|
|
||||||
|
// Store lineup data for populating after rosters load
|
||||||
|
if (status.home_lineup) pendingHomeLineup.value = status.home_lineup
|
||||||
|
if (status.away_lineup) pendingAwayLineup.value = status.away_lineup
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check lineup status:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate lineup slots from stored lineup data (call after rosters load)
|
||||||
|
function populateLineupsFromData() {
|
||||||
|
// Populate home lineup
|
||||||
|
if (pendingHomeLineup.value.length > 0 && homeRoster.value.length > 0) {
|
||||||
|
console.log('[LineupBuilder] Populating home lineup from saved data')
|
||||||
|
for (const entry of pendingHomeLineup.value) {
|
||||||
|
const player = homeRoster.value.find(p => p.id === entry.player_id)
|
||||||
|
if (player) {
|
||||||
|
// Determine slot index: batting_order 1-9 maps to slots 0-8, null (pitcher only) maps to slot 9
|
||||||
|
const slotIndex = entry.batting_order ? entry.batting_order - 1 : 9
|
||||||
|
if (slotIndex >= 0 && slotIndex < 10) {
|
||||||
|
homeLineup.value[slotIndex] = {
|
||||||
|
player,
|
||||||
|
position: entry.position,
|
||||||
|
battingOrder: entry.batting_order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate away lineup
|
||||||
|
if (pendingAwayLineup.value.length > 0 && awayRoster.value.length > 0) {
|
||||||
|
console.log('[LineupBuilder] Populating away lineup from saved data')
|
||||||
|
for (const entry of pendingAwayLineup.value) {
|
||||||
|
const player = awayRoster.value.find(p => p.id === entry.player_id)
|
||||||
|
if (player) {
|
||||||
|
const slotIndex = entry.batting_order ? entry.batting_order - 1 : 9
|
||||||
|
if (slotIndex >= 0 && slotIndex < 10) {
|
||||||
|
awayLineup.value[slotIndex] = {
|
||||||
|
player,
|
||||||
|
position: entry.position,
|
||||||
|
battingOrder: entry.batting_order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchGameData()
|
await fetchGameData()
|
||||||
|
|
||||||
|
// Check if lineups already submitted (also gets lineup data if available)
|
||||||
|
await checkLineupStatus()
|
||||||
|
|
||||||
|
// Fetch rosters
|
||||||
if (homeTeamId.value) {
|
if (homeTeamId.value) {
|
||||||
homeRoster.value = await fetchRoster(homeTeamId.value)
|
homeRoster.value = await fetchRoster(homeTeamId.value)
|
||||||
}
|
}
|
||||||
if (awayTeamId.value) {
|
if (awayTeamId.value) {
|
||||||
awayRoster.value = await fetchRoster(awayTeamId.value)
|
awayRoster.value = await fetchRoster(awayTeamId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate lineup slots from saved data (now that rosters are loaded)
|
||||||
|
populateLineupsFromData()
|
||||||
|
|
||||||
|
// If teamId prop is provided, switch to that team's tab
|
||||||
|
if (props.teamId) {
|
||||||
|
if (props.teamId === homeTeamId.value) {
|
||||||
|
activeTab.value = 'home'
|
||||||
|
} else if (props.teamId === awayTeamId.value) {
|
||||||
|
activeTab.value = 'away'
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gradient-to-b from-gray-900 via-gray-900 to-gray-950 text-white">
|
<div class="min-h-screen bg-gradient-to-b from-gray-900 via-gray-900 to-gray-950 text-white">
|
||||||
<!-- Header Bar -->
|
<!-- Header Bar -->
|
||||||
<div class="sticky top-0 z-40 bg-gray-900/95 backdrop-blur-sm border-b border-gray-800">
|
<div class="bg-gray-900/95 backdrop-blur-sm border-b border-gray-800">
|
||||||
<div class="max-w-6xl mx-auto px-4 py-3">
|
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div>
|
||||||
<NuxtLink
|
<h1 class="text-xl font-bold">Build Your Lineup</h1>
|
||||||
:to="`/games/${gameId}`"
|
<p class="text-xs text-gray-500 hidden sm:block">Drag players to assign positions</p>
|
||||||
class="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<span class="text-lg">←</span>
|
|
||||||
<span class="hidden sm:inline">Back to Game</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<div class="h-6 w-px bg-gray-700 hidden sm:block" />
|
|
||||||
<div>
|
|
||||||
<h1 class="text-xl font-bold">Build Your Lineup</h1>
|
|
||||||
<p class="text-xs text-gray-500 hidden sm:block">Drag players to assign positions</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-400">
|
<div class="text-sm text-gray-400">
|
||||||
Game #{{ gameId.slice(0, 8) }}
|
Game #{{ gameId.slice(0, 8) }}
|
||||||
@ -737,7 +935,7 @@ onMounted(async () => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else class="text-gray-600 text-xs text-center">
|
<div v-else class="text-gray-600 text-xs text-center">
|
||||||
—
|
-
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -857,18 +1055,53 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit button -->
|
<!-- Submit button (per-team) -->
|
||||||
<div class="sticky bottom-4 pt-4">
|
<div class="sticky bottom-4 pt-4 space-y-3">
|
||||||
|
<!-- Already submitted status -->
|
||||||
|
<div
|
||||||
|
v-if="isActiveTeamSubmitted"
|
||||||
|
class="bg-green-950/50 border border-green-800/50 rounded-xl p-4 text-center"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center gap-2 text-green-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold">{{ activeTab === 'home' ? 'Home' : 'Away' }} Lineup Submitted</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
|
{{ (activeTab === 'home' ? awaySubmitted : homeSubmitted) ? 'Both teams ready!' : 'Waiting for other team...' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit button -->
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
v-else
|
||||||
variant="success"
|
variant="success"
|
||||||
size="lg"
|
size="lg"
|
||||||
full-width
|
full-width
|
||||||
:disabled="!canSubmit"
|
:disabled="!canSubmitActiveTeam"
|
||||||
:loading="submittingLineups"
|
:loading="isSubmittingActiveTeam"
|
||||||
@click="submitLineups"
|
@click="submitActiveTeamLineup"
|
||||||
>
|
>
|
||||||
{{ submittingLineups ? 'Submitting Lineups...' : (canSubmit ? 'Submit Lineups & Start Game' : 'Complete Both Lineups to Continue') }}
|
{{ isSubmittingActiveTeam
|
||||||
|
? 'Submitting...'
|
||||||
|
: (canSubmitActiveTeam
|
||||||
|
? `Submit ${activeTab === 'home' ? 'Home' : 'Away'} Lineup`
|
||||||
|
: 'Complete Lineup to Submit')
|
||||||
|
}}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
|
<!-- Status indicators for both teams -->
|
||||||
|
<div class="flex justify-center gap-4 text-xs">
|
||||||
|
<div :class="homeSubmitted ? 'text-green-400' : 'text-gray-500'">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full mr-1" :class="homeSubmitted ? 'bg-green-400' : 'bg-gray-600'" />
|
||||||
|
Home: {{ homeSubmitted ? 'Submitted' : 'Pending' }}
|
||||||
|
</div>
|
||||||
|
<div :class="awaySubmitted ? 'text-green-400' : 'text-gray-500'">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full mr-1" :class="awaySubmitted ? 'bg-green-400' : 'bg-gray-600'" />
|
||||||
|
Away: {{ awaySubmitted ? 'Submitted' : 'Pending' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -903,7 +1136,7 @@ onMounted(async () => {
|
|||||||
class="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-900/80 hover:bg-gray-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
|
class="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-900/80 hover:bg-gray-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
|
||||||
@click="closePlayerPreview"
|
@click="closePlayerPreview"
|
||||||
>
|
>
|
||||||
×
|
x
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -937,7 +1170,7 @@ onMounted(async () => {
|
|||||||
<div class="bg-gray-700/50 rounded-lg p-3">
|
<div class="bg-gray-700/50 rounded-lg p-3">
|
||||||
<div class="text-xs text-gray-400 uppercase tracking-wide mb-1">WAR</div>
|
<div class="text-xs text-gray-400 uppercase tracking-wide mb-1">WAR</div>
|
||||||
<div class="text-xl font-bold">
|
<div class="text-xl font-bold">
|
||||||
{{ previewPlayer.wara?.toFixed(1) || '—' }}
|
{{ previewPlayer.wara?.toFixed(1) || '-' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<!-- Sticky ScoreBoard Header -->
|
<!-- Sticky Header: ScoreBoard + Tabs -->
|
||||||
<div ref="scoreBoardRef" class="sticky top-0 z-20">
|
<div class="sticky top-0 z-30">
|
||||||
|
<!-- ScoreBoard -->
|
||||||
<ScoreBoard
|
<ScoreBoard
|
||||||
:home-score="gameState?.home_score"
|
:home-score="gameState?.home_score"
|
||||||
:away-score="gameState?.away_score"
|
:away-score="gameState?.away_score"
|
||||||
@ -10,353 +11,55 @@
|
|||||||
:outs="gameState?.outs"
|
:outs="gameState?.outs"
|
||||||
:runners="runnersState"
|
:runners="runnersState"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Game Container -->
|
<!-- Tab Navigation -->
|
||||||
<div class="container mx-auto px-4 py-6 lg:py-8">
|
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
<!-- Connection Error Banner (Permanently Failed) -->
|
<div class="container mx-auto">
|
||||||
<div
|
<div class="flex">
|
||||||
v-if="permanentlyFailed"
|
|
||||||
class="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p class="text-sm font-medium text-red-800">
|
|
||||||
Connection Failed
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-red-700 mt-1">
|
|
||||||
Unable to connect to the game server after multiple attempts. Please check your internet connection and try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="manualRetry"
|
|
||||||
class="ml-4 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition flex-shrink-0"
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection Status Banner (Reconnecting) -->
|
|
||||||
<div
|
|
||||||
v-else-if="!isConnected"
|
|
||||||
class="mb-4 bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-yellow-400 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p class="text-sm text-yellow-700">
|
|
||||||
{{ connectionStatus === 'connecting' ? 'Connecting to game server...' : 'Disconnected from server. Attempting to reconnect...' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="forceReconnect"
|
|
||||||
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-700 bg-yellow-100 hover:bg-yellow-200 rounded-md transition"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Layout (Stacked) -->
|
|
||||||
<div class="lg:hidden space-y-6">
|
|
||||||
<!-- Current Situation -->
|
|
||||||
<CurrentSituation
|
|
||||||
:current-batter="gameState?.current_batter"
|
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Game Board -->
|
|
||||||
<GameBoard
|
|
||||||
:runners="runnersState"
|
|
||||||
:current-batter="gameState?.current_batter"
|
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Play-by-Play Feed -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-md">
|
|
||||||
<PlayByPlay
|
|
||||||
:plays="playHistory"
|
|
||||||
:limit="5"
|
|
||||||
:compact="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Decision Panel (Phase F3) -->
|
|
||||||
<DecisionPanel
|
|
||||||
v-if="showDecisions"
|
|
||||||
:game-id="gameId"
|
|
||||||
:current-team="currentTeam"
|
|
||||||
:is-my-turn="isMyTurn"
|
|
||||||
:phase="decisionPhase"
|
|
||||||
:runners="runnersData"
|
|
||||||
:current-defensive-setup="pendingDefensiveSetup ?? undefined"
|
|
||||||
:current-offensive-decision="pendingOffensiveDecision ?? undefined"
|
|
||||||
:current-steal-attempts="pendingStealAttempts"
|
|
||||||
:decision-history="decisionHistory"
|
|
||||||
@defensive-submit="handleDefensiveSubmit"
|
|
||||||
@offensive-submit="handleOffensiveSubmit"
|
|
||||||
@steal-attempts-submit="handleStealAttemptsSubmit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Gameplay Panel (Phase F4) -->
|
|
||||||
<GameplayPanel
|
|
||||||
v-if="showGameplay"
|
|
||||||
:game-id="gameId"
|
|
||||||
:is-my-turn="isMyTurn"
|
|
||||||
:can-roll-dice="canRollDice"
|
|
||||||
:pending-roll="pendingRoll"
|
|
||||||
:last-play-result="lastPlayResult"
|
|
||||||
:can-submit-outcome="canSubmitOutcome"
|
|
||||||
:outs="gameState?.outs ?? 0"
|
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
|
||||||
@roll-dice="handleRollDice"
|
|
||||||
@submit-outcome="handleSubmitOutcome"
|
|
||||||
@dismiss-result="handleDismissResult"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop Layout (Grid) -->
|
|
||||||
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
|
||||||
<!-- Left Column: Game State -->
|
|
||||||
<div class="lg:col-span-2 space-y-6">
|
|
||||||
<!-- Current Situation -->
|
|
||||||
<CurrentSituation
|
|
||||||
:current-batter="gameState?.current_batter"
|
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Game Board -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
|
||||||
<GameBoard
|
|
||||||
:runners="runnersState"
|
|
||||||
:current-batter="gameState?.current_batter"
|
|
||||||
:current-pitcher="gameState?.current_pitcher"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Decision Panel (Phase F3) -->
|
|
||||||
<DecisionPanel
|
|
||||||
v-if="showDecisions"
|
|
||||||
:game-id="gameId"
|
|
||||||
:current-team="currentTeam"
|
|
||||||
:is-my-turn="isMyTurn"
|
|
||||||
:phase="decisionPhase"
|
|
||||||
:runners="runnersData"
|
|
||||||
:current-defensive-setup="pendingDefensiveSetup ?? undefined"
|
|
||||||
:current-offensive-decision="pendingOffensiveDecision ?? undefined"
|
|
||||||
:current-steal-attempts="pendingStealAttempts"
|
|
||||||
:decision-history="decisionHistory"
|
|
||||||
@defensive-submit="handleDefensiveSubmit"
|
|
||||||
@offensive-submit="handleOffensiveSubmit"
|
|
||||||
@steal-attempts-submit="handleStealAttemptsSubmit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Gameplay Panel (Phase F4) -->
|
|
||||||
<GameplayPanel
|
|
||||||
v-if="showGameplay"
|
|
||||||
:game-id="gameId"
|
|
||||||
:is-my-turn="isMyTurn"
|
|
||||||
:can-roll-dice="canRollDice"
|
|
||||||
:pending-roll="pendingRoll"
|
|
||||||
:last-play-result="lastPlayResult"
|
|
||||||
:can-submit-outcome="canSubmitOutcome"
|
|
||||||
:outs="gameState?.outs ?? 0"
|
|
||||||
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
|
||||||
@roll-dice="handleRollDice"
|
|
||||||
@submit-outcome="handleSubmitOutcome"
|
|
||||||
@dismiss-result="handleDismissResult"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Play-by-Play -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<div
|
|
||||||
class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg sticky"
|
|
||||||
:style="{ top: `${scoreBoardHeight + 16}px` }"
|
|
||||||
>
|
|
||||||
<PlayByPlay
|
|
||||||
:plays="playHistory"
|
|
||||||
:scrollable="true"
|
|
||||||
:max-height="600"
|
|
||||||
:show-filters="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-2xl text-center max-w-sm mx-4">
|
|
||||||
<!-- Show spinner only if actively connecting -->
|
|
||||||
<div v-if="isConnecting" class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
|
|
||||||
|
|
||||||
<!-- Show error state if not connecting -->
|
|
||||||
<div v-else class="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-900 dark:text-white font-semibold">
|
|
||||||
{{ isConnecting ? 'Connecting to game...' : permanentlyFailed ? 'Connection Failed' : 'Reconnecting...' }}
|
|
||||||
</p>
|
|
||||||
<p v-if="permanentlyFailed" class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Unable to reach server after multiple attempts
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Status info -->
|
|
||||||
<div class="mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-left">
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span :class="authStore.isAuthenticated ? 'text-green-600' : authStore.isLoading ? 'text-yellow-600' : 'text-red-600'">●</span>
|
|
||||||
<span class="text-gray-700 dark:text-gray-300">Auth: {{ authStore.isAuthenticated ? 'OK' : authStore.isLoading ? 'Checking...' : 'Failed' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-sm mt-1">
|
|
||||||
<span :class="isConnected ? 'text-green-600' : isConnecting ? 'text-yellow-600' : permanentlyFailed ? 'text-red-600' : 'text-orange-500'">●</span>
|
|
||||||
<span class="text-gray-700 dark:text-gray-300">WebSocket: {{ isConnected ? 'Connected' : isConnecting ? 'Connecting...' : permanentlyFailed ? 'Failed' : 'Reconnecting...' }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="connectionError" class="text-xs text-red-600 mt-2">{{ connectionError }}</p>
|
|
||||||
<!-- Debug info -->
|
|
||||||
<div class="mt-2 text-xs text-gray-500 border-t pt-2">
|
|
||||||
<p>WS URL: {{ wsDebugUrl }}</p>
|
|
||||||
<p>Socket exists: {{ socketExists }}</p>
|
|
||||||
<p class="mt-1 font-mono text-[10px] max-h-24 overflow-y-auto">{{ debugLog }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="mt-4 flex flex-col gap-2">
|
|
||||||
<button
|
<button
|
||||||
@click="permanentlyFailed ? manualRetry() : forceReconnect()"
|
v-for="tab in tabs"
|
||||||
:class="permanentlyFailed ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'"
|
:key="tab.id"
|
||||||
class="w-full px-4 py-2 text-sm font-medium text-white rounded-lg transition"
|
:class="[
|
||||||
|
'flex-1 py-3 px-4 text-sm font-medium text-center transition-colors relative',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-primary dark:text-blue-400'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
>
|
>
|
||||||
{{ permanentlyFailed ? 'Try Again' : 'Retry Connection' }}
|
{{ tab.label }}
|
||||||
</button>
|
<!-- Active indicator -->
|
||||||
<button
|
<span
|
||||||
v-if="!authStore.isAuthenticated"
|
v-if="activeTab === tab.id"
|
||||||
@click="navigateTo('/auth/login?return_url=' + encodeURIComponent($route.fullPath))"
|
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary dark:bg-blue-400"
|
||||||
class="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded-lg transition"
|
/>
|
||||||
>
|
|
||||||
Re-Login
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="isLoading = false"
|
|
||||||
class="w-full px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
|
||||||
>
|
|
||||||
Dismiss (view page anyway)
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Not Started State -->
|
|
||||||
<div
|
|
||||||
v-if="gameState && gameState.status === 'pending'"
|
|
||||||
class="mt-6 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-700 rounded-xl p-8 text-center"
|
|
||||||
>
|
|
||||||
<div class="w-20 h-20 mx-auto mb-4 bg-blue-100 dark:bg-blue-800 rounded-full flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">Game Starting Soon</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Waiting for all players to join. The game will begin once everyone is ready.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Game Ended State -->
|
|
||||||
<div
|
|
||||||
v-if="gameState && gameState.status === 'completed'"
|
|
||||||
class="mt-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-700 rounded-xl p-8 text-center"
|
|
||||||
>
|
|
||||||
<div class="w-20 h-20 mx-auto mb-4 bg-green-100 dark:bg-green-800 rounded-full flex items-center justify-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Game Complete!</h3>
|
|
||||||
<p class="text-xl text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Final Score: {{ gameState.away_score }} - {{ gameState.home_score }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="px-6 py-3 bg-primary hover:bg-blue-700 text-white rounded-lg font-semibold transition shadow-md"
|
|
||||||
@click="navigateTo('/')"
|
|
||||||
>
|
|
||||||
Back to Games
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Substitution Panel Modal (Phase F5) -->
|
<!-- Tab Content -->
|
||||||
<Teleport to="body">
|
<div class="tab-content">
|
||||||
<div
|
<!-- Game Tab (use v-show to keep WebSocket connected) -->
|
||||||
v-if="showSubstitutions"
|
<GamePlay
|
||||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
v-show="activeTab === 'game'"
|
||||||
@click.self="handleSubstitutionCancel"
|
:game-id="gameId"
|
||||||
>
|
/>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<SubstitutionPanel
|
<!-- Lineups Tab (use v-show to preserve state when switching tabs) -->
|
||||||
v-if="myTeamId"
|
<div v-show="activeTab === 'lineups'" class="container mx-auto px-4 py-6">
|
||||||
:game-id="gameId"
|
<LineupBuilder
|
||||||
:team-id="myTeamId"
|
:game-id="gameId"
|
||||||
:current-lineup="currentLineup"
|
:team-id="myManagedTeamId"
|
||||||
:bench-players="benchPlayers"
|
@lineups-submitted="handleLineupsSubmitted"
|
||||||
:current-pitcher="currentPitcher"
|
/>
|
||||||
:current-batter="currentBatter"
|
|
||||||
@pinch-hitter="handlePinchHitter"
|
|
||||||
@defensive-replacement="handleDefensiveReplacement"
|
|
||||||
@pitching-change="handlePitchingChange"
|
|
||||||
@cancel="handleSubstitutionCancel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- Floating Action Buttons -->
|
<!-- Stats Tab -->
|
||||||
<div class="fixed bottom-6 right-6 flex flex-col gap-3 z-40">
|
<div v-show="activeTab === 'stats'" class="container mx-auto px-4 py-6">
|
||||||
<!-- Undo Last Play Button -->
|
<GameStats :game-id="gameId" />
|
||||||
<button
|
</div>
|
||||||
v-if="canUndo"
|
|
||||||
class="w-14 h-14 bg-amber-500 hover:bg-amber-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
|
||||||
aria-label="Undo Last Play"
|
|
||||||
title="Undo Last Play"
|
|
||||||
@click="handleUndoLastPlay"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Substitutions Button -->
|
|
||||||
<button
|
|
||||||
v-if="canMakeSubstitutions"
|
|
||||||
class="w-16 h-16 bg-primary hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
|
|
||||||
aria-label="Open Substitutions"
|
|
||||||
@click="showSubstitutions = true"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -365,103 +68,41 @@
|
|||||||
import { useGameStore } from '~/store/game'
|
import { useGameStore } from '~/store/game'
|
||||||
import { useAuthStore } from '~/store/auth'
|
import { useAuthStore } from '~/store/auth'
|
||||||
import { useUiStore } from '~/store/ui'
|
import { useUiStore } from '~/store/ui'
|
||||||
import { useWebSocket } from '~/composables/useWebSocket'
|
|
||||||
import { useGameActions } from '~/composables/useGameActions'
|
|
||||||
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
import ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
||||||
import GameBoard from '~/components/Game/GameBoard.vue'
|
import GamePlay from '~/components/Game/GamePlay.vue'
|
||||||
import CurrentSituation from '~/components/Game/CurrentSituation.vue'
|
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
||||||
import PlayByPlay from '~/components/Game/PlayByPlay.vue'
|
import GameStats from '~/components/Game/GameStats.vue'
|
||||||
import DecisionPanel from '~/components/Decisions/DecisionPanel.vue'
|
|
||||||
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
|
||||||
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.vue'
|
|
||||||
import type { DefensiveDecision, OffensiveDecision, PlayOutcome, RollData, PlayResult } from '~/types/game'
|
|
||||||
import type { Lineup } from '~/types/player'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'game',
|
layout: 'game',
|
||||||
middleware: ['auth'],
|
middleware: ['auth'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Stores
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
// Auth is initialized by the auth plugin automatically
|
// Game ID from route
|
||||||
|
|
||||||
// Get game ID from route
|
|
||||||
const gameId = computed(() => route.params.id as string)
|
const gameId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
// WebSocket connection
|
// Tab state
|
||||||
const { socket, isConnected, isConnecting, connectionError, permanentlyFailed, connect, forceReconnect, manualRetry } = useWebSocket()
|
type GameTab = 'game' | 'lineups' | 'stats'
|
||||||
|
const activeTab = ref<GameTab>('game')
|
||||||
|
const defaultTabSet = ref(false)
|
||||||
|
|
||||||
// Debug info for troubleshooting Safari WebSocket issues
|
// Tab definitions
|
||||||
const config = useRuntimeConfig()
|
const tabs = [
|
||||||
const wsDebugUrl = computed(() => config.public.wsUrl || 'not set')
|
{ id: 'game' as const, label: 'Game' },
|
||||||
const socketExists = computed(() => socket.value ? 'yes' : 'no')
|
{ id: 'lineups' as const, label: 'Lineups' },
|
||||||
const debugLog = ref('Loading...')
|
{ id: 'stats' as const, label: 'Stats' },
|
||||||
|
]
|
||||||
|
|
||||||
// Pass the raw string value from route params, not computed value
|
// Game state from store (populated by GamePlay component via WebSocket)
|
||||||
// useGameActions will create its own computed internally if needed
|
const gameState = computed(() => gameStore.gameState)
|
||||||
const actions = useGameActions(route.params.id as string)
|
|
||||||
|
|
||||||
// Destructure undoLastPlay for the undo button
|
// Runners state for ScoreBoard
|
||||||
const { undoLastPlay } = actions
|
|
||||||
|
|
||||||
// Game state from store
|
|
||||||
const gameState = computed(() => {
|
|
||||||
const state = gameStore.gameState
|
|
||||||
if (state) {
|
|
||||||
const batterInfo = state.current_batter
|
|
||||||
? `lineup_id=${state.current_batter.lineup_id}, batting_order=${state.current_batter.batting_order}`
|
|
||||||
: 'None'
|
|
||||||
console.log('[Game Page] gameState computed - current_batter:', batterInfo)
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
const playHistory = computed(() => gameStore.playHistory)
|
|
||||||
const canRollDice = computed(() => gameStore.canRollDice)
|
|
||||||
const canSubmitOutcome = computed(() => gameStore.canSubmitOutcome)
|
|
||||||
const pendingDefensiveSetup = computed(() => gameStore.pendingDefensiveSetup)
|
|
||||||
const pendingOffensiveDecision = computed(() => gameStore.pendingOffensiveDecision)
|
|
||||||
const pendingStealAttempts = computed(() => gameStore.pendingStealAttempts)
|
|
||||||
const decisionHistory = computed(() => gameStore.decisionHistory)
|
|
||||||
const needsDefensiveDecision = computed(() => gameStore.needsDefensiveDecision)
|
|
||||||
const needsOffensiveDecision = computed(() => gameStore.needsOffensiveDecision)
|
|
||||||
const basesEmpty = computed(() => gameStore.basesEmpty)
|
|
||||||
const pendingRoll = computed(() => gameStore.pendingRoll)
|
|
||||||
const lastPlayResult = computed(() => gameStore.lastPlayResult)
|
|
||||||
const currentDecisionPrompt = computed(() => gameStore.currentDecisionPrompt)
|
|
||||||
|
|
||||||
// Local UI state
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
|
||||||
const showSubstitutions = ref(false)
|
|
||||||
|
|
||||||
// Determine which team the user controls
|
|
||||||
// For demo/testing: user controls whichever team needs to act
|
|
||||||
const myTeamId = computed(() => {
|
|
||||||
if (!gameState.value) return null
|
|
||||||
|
|
||||||
// Return the team that currently needs to make a decision
|
|
||||||
if (gameState.value.half === 'top') {
|
|
||||||
// Top: away bats, home fields
|
|
||||||
return gameState.value.decision_phase === 'awaiting_defensive'
|
|
||||||
? gameState.value.home_team_id
|
|
||||||
: gameState.value.away_team_id
|
|
||||||
} else {
|
|
||||||
// Bottom: home bats, away fields
|
|
||||||
return gameState.value.decision_phase === 'awaiting_defensive'
|
|
||||||
? gameState.value.away_team_id
|
|
||||||
: gameState.value.home_team_id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Dynamic ScoreBoard height tracking
|
|
||||||
const scoreBoardRef = ref<HTMLElement | null>(null)
|
|
||||||
const scoreBoardHeight = ref(0)
|
|
||||||
|
|
||||||
// Computed helpers
|
|
||||||
const runnersState = computed(() => {
|
const runnersState = computed(() => {
|
||||||
if (!gameState.value) {
|
if (!gameState.value) {
|
||||||
return { first: false, second: false, third: false }
|
return { first: false, second: false, third: false }
|
||||||
@ -474,410 +115,68 @@ const runnersState = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const runnersData = computed(() => {
|
// Check if user is a manager of either team in this game
|
||||||
return {
|
const isUserManager = computed(() => {
|
||||||
first: gameState.value?.on_first ?? null,
|
if (!gameState.value) return false
|
||||||
second: gameState.value?.on_second ?? null,
|
const userTeams = authStore.userTeamIds
|
||||||
third: gameState.value?.on_third ?? null,
|
|
||||||
}
|
return userTeams.includes(gameState.value.home_team_id) ||
|
||||||
|
userTeams.includes(gameState.value.away_team_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTeam = computed(() => {
|
// Determine which team the user manages (if any)
|
||||||
return gameState.value?.half === 'top' ? 'away' : 'home'
|
const myManagedTeamId = computed(() => {
|
||||||
|
if (!gameState.value) return null
|
||||||
|
const userTeams = authStore.userTeamIds
|
||||||
|
|
||||||
|
// Check home team first
|
||||||
|
if (userTeams.includes(gameState.value.home_team_id)) {
|
||||||
|
return gameState.value.home_team_id
|
||||||
|
}
|
||||||
|
// Then away team
|
||||||
|
if (userTeams.includes(gameState.value.away_team_id)) {
|
||||||
|
return gameState.value.away_team_id
|
||||||
|
}
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Determine if it's the current user's turn to act
|
// Set default tab based on game status and user role
|
||||||
const isMyTurn = computed(() => {
|
watch(gameState, (state) => {
|
||||||
if (!myTeamId.value || !gameState.value) return false
|
if (!state) return
|
||||||
|
|
||||||
// During decision phases, check which team needs to decide
|
// Only set default once (don't override user navigation)
|
||||||
if (needsDefensiveDecision.value) {
|
if (defaultTabSet.value) return
|
||||||
// Fielding team makes defensive decisions
|
defaultTabSet.value = true
|
||||||
const fieldingTeamId = gameState.value.half === 'top'
|
|
||||||
? gameState.value.home_team_id
|
// Completed games → Stats tab
|
||||||
: gameState.value.away_team_id
|
if (state.status === 'completed') {
|
||||||
return myTeamId.value === fieldingTeamId
|
console.log('[Game Page] Game completed - defaulting to Stats tab')
|
||||||
|
activeTab.value = 'stats'
|
||||||
}
|
}
|
||||||
|
// Pending games + manager → Lineups tab
|
||||||
if (needsOffensiveDecision.value) {
|
else if (state.status === 'pending' && isUserManager.value) {
|
||||||
// Batting team makes offensive decisions
|
console.log('[Game Page] Pending game + manager - defaulting to Lineups tab')
|
||||||
const battingTeamId = gameState.value.half === 'top'
|
activeTab.value = 'lineups'
|
||||||
? gameState.value.away_team_id
|
|
||||||
: gameState.value.home_team_id
|
|
||||||
return myTeamId.value === battingTeamId
|
|
||||||
}
|
}
|
||||||
|
// All other cases → Game tab (already default)
|
||||||
// During resolution phase (dice rolling, outcome submission),
|
else {
|
||||||
// the BATTING team has control (they read their card)
|
console.log('[Game Page] Defaulting to Game tab (status:', state.status, ', isManager:', isUserManager.value, ')')
|
||||||
const battingTeamId = gameState.value.half === 'top'
|
|
||||||
? gameState.value.away_team_id
|
|
||||||
: gameState.value.home_team_id
|
|
||||||
return myTeamId.value === battingTeamId
|
|
||||||
})
|
|
||||||
|
|
||||||
const decisionPhase = computed(() => {
|
|
||||||
if (needsDefensiveDecision.value) return 'defensive'
|
|
||||||
if (needsOffensiveDecision.value) return 'offensive'
|
|
||||||
return 'idle'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Phase F6: Conditional panel rendering
|
|
||||||
const showDecisions = computed(() => {
|
|
||||||
// Don't show decision panels if there's a result pending dismissal
|
|
||||||
if (lastPlayResult.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = gameState.value?.status === 'active' &&
|
|
||||||
isMyTurn.value &&
|
|
||||||
(needsDefensiveDecision.value || needsOffensiveDecision.value)
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('[Game Page] Panel visibility check:', {
|
|
||||||
gameStatus: gameState.value?.status,
|
|
||||||
isMyTurn: isMyTurn.value,
|
|
||||||
needsDefensiveDecision: needsDefensiveDecision.value,
|
|
||||||
needsOffensiveDecision: needsOffensiveDecision.value,
|
|
||||||
decision_phase: gameState.value?.decision_phase,
|
|
||||||
showDecisions: result,
|
|
||||||
currentDecisionPrompt: currentDecisionPrompt.value,
|
|
||||||
hasLastPlayResult: !!lastPlayResult.value
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const showGameplay = computed(() => {
|
|
||||||
// Show gameplay panel if there's a result to display OR if we're in the resolution phase
|
|
||||||
if (lastPlayResult.value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return gameState.value?.status === 'active' &&
|
|
||||||
isMyTurn.value &&
|
|
||||||
!needsDefensiveDecision.value &&
|
|
||||||
!needsOffensiveDecision.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canMakeSubstitutions = computed(() => {
|
|
||||||
return gameState.value?.status === 'active' && isMyTurn.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canUndo = computed(() => {
|
|
||||||
// Can only undo if game is active and there are plays to undo
|
|
||||||
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Lineup helpers for substitutions
|
|
||||||
const currentLineup = computed(() => {
|
|
||||||
if (!myTeamId.value) return []
|
|
||||||
return myTeamId.value === gameState.value?.home_team_id
|
|
||||||
? gameStore.homeLineup.filter(l => l.is_active)
|
|
||||||
: gameStore.awayLineup.filter(l => l.is_active)
|
|
||||||
})
|
|
||||||
|
|
||||||
const benchPlayers = computed(() => {
|
|
||||||
if (!myTeamId.value) return []
|
|
||||||
return myTeamId.value === gameState.value?.home_team_id
|
|
||||||
? gameStore.homeLineup.filter(l => !l.is_active)
|
|
||||||
: gameStore.awayLineup.filter(l => !l.is_active)
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentBatter = computed(() => {
|
|
||||||
const batterState = gameState.value?.current_batter
|
|
||||||
if (!batterState) return null
|
|
||||||
return gameStore.findPlayerInLineup(batterState.lineup_id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentPitcher = computed(() => {
|
|
||||||
const pitcherState = gameState.value?.current_pitcher
|
|
||||||
if (!pitcherState) return null
|
|
||||||
return gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods - Gameplay (Phase F4)
|
|
||||||
const handleRollDice = async () => {
|
|
||||||
console.log('[Game Page] Rolling dice')
|
|
||||||
try {
|
|
||||||
await actions.rollDice()
|
|
||||||
// The dice_rolled event will update pendingRoll via WebSocket
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to roll dice:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitOutcome = async (data: { outcome: PlayOutcome; hitLocation?: string }) => {
|
|
||||||
console.log('[Game Page] Submitting outcome:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitManualOutcome(
|
|
||||||
data.outcome,
|
|
||||||
data.hitLocation
|
|
||||||
)
|
|
||||||
// Pending roll will be cleared by backend after successful submission
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to submit outcome:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDismissResult = () => {
|
|
||||||
console.log('[Game Page] Dismissing result')
|
|
||||||
gameStore.clearLastPlayResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDefensiveSubmit = async (decision: DefensiveDecision) => {
|
|
||||||
console.log('[Game Page] Submitting defensive decision:', decision)
|
|
||||||
try {
|
|
||||||
await actions.submitDefensiveDecision(decision)
|
|
||||||
gameStore.setPendingDefensiveSetup(decision)
|
|
||||||
gameStore.addDecisionToHistory('Defensive', `${decision.infield_depth} infield, ${decision.outfield_depth} outfield`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to submit defensive decision:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOffensiveSubmit = async (decision: Omit<OffensiveDecision, 'steal_attempts'>) => {
|
|
||||||
console.log('[Game Page] Submitting offensive decision:', decision)
|
|
||||||
try {
|
|
||||||
// Combine with steal attempts
|
|
||||||
const fullDecision: OffensiveDecision = {
|
|
||||||
...decision,
|
|
||||||
steal_attempts: pendingStealAttempts.value,
|
|
||||||
}
|
|
||||||
await actions.submitOffensiveDecision(fullDecision)
|
|
||||||
gameStore.setPendingOffensiveDecision(decision)
|
|
||||||
const actionLabels: Record<string, string> = {
|
|
||||||
swing_away: 'Swing Away',
|
|
||||||
steal: 'Steal',
|
|
||||||
check_jump: 'Check Jump',
|
|
||||||
hit_and_run: 'Hit & Run',
|
|
||||||
sac_bunt: 'Sac Bunt',
|
|
||||||
squeeze_bunt: 'Squeeze Bunt',
|
|
||||||
}
|
|
||||||
gameStore.addDecisionToHistory('Offensive', actionLabels[decision.action] || decision.action)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to submit offensive decision:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStealAttemptsSubmit = (attempts: number[]) => {
|
|
||||||
console.log('[Game Page] Updating steal attempts:', attempts)
|
|
||||||
gameStore.setPendingStealAttempts(attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods - Substitutions (Phase F5)
|
|
||||||
const handlePinchHitter = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
||||||
console.log('[Game Page] Submitting pinch hitter:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitSubstitution(
|
|
||||||
'pinch_hitter',
|
|
||||||
data.playerOutLineupId,
|
|
||||||
data.playerInCardId,
|
|
||||||
data.teamId
|
|
||||||
)
|
|
||||||
showSubstitutions.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to submit pinch hitter:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDefensiveReplacement = async (data: { playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }) => {
|
|
||||||
console.log('[Game Page] Submitting defensive replacement:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitSubstitution(
|
|
||||||
'defensive_replacement',
|
|
||||||
data.playerOutLineupId,
|
|
||||||
data.playerInCardId,
|
|
||||||
data.teamId,
|
|
||||||
data.newPosition
|
|
||||||
)
|
|
||||||
showSubstitutions.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to submit defensive replacement:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePitchingChange = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
||||||
console.log('[Game Page] Submitting pitching change:', data)
|
|
||||||
try {
|
|
||||||
await actions.submitSubstitution(
|
|
||||||
'pitching_change',
|
|
||||||
data.playerOutLineupId,
|
|
||||||
data.playerInCardId,
|
|
||||||
data.teamId
|
|
||||||
)
|
|
||||||
showSubstitutions.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Game Page] Failed to submit pitching change:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubstitutionCancel = () => {
|
|
||||||
console.log('[Game Page] Cancelling substitution')
|
|
||||||
showSubstitutions.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo handler
|
|
||||||
const handleUndoLastPlay = () => {
|
|
||||||
console.log('[Game Page] Undoing last play')
|
|
||||||
undoLastPlay(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry connection with auth check
|
|
||||||
const retryWithAuth = async () => {
|
|
||||||
console.log('[Game Page] Retry with auth check')
|
|
||||||
isLoading.value = true
|
|
||||||
connectionStatus.value = 'connecting'
|
|
||||||
|
|
||||||
// First check auth
|
|
||||||
const isAuthed = await authStore.checkAuth()
|
|
||||||
console.log('[Game Page] Auth result:', isAuthed, 'isAuthenticated:', authStore.isAuthenticated)
|
|
||||||
|
|
||||||
if (!isAuthed) {
|
|
||||||
console.error('[Game Page] Auth failed')
|
|
||||||
isLoading.value = false
|
|
||||||
connectionStatus.value = 'disconnected'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force reconnect
|
|
||||||
forceReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure ScoreBoard height dynamically
|
|
||||||
const updateScoreBoardHeight = () => {
|
|
||||||
if (scoreBoardRef.value) {
|
|
||||||
scoreBoardHeight.value = scoreBoardRef.value.offsetHeight
|
|
||||||
console.log('[Game Page] ScoreBoard height:', scoreBoardHeight.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(async () => {
|
|
||||||
// Debug logging for Safari troubleshooting
|
|
||||||
debugLog.value = `Mounted at ${new Date().toLocaleTimeString()}\n`
|
|
||||||
debugLog.value += `isConnected: ${isConnected.value}, isConnecting: ${isConnecting.value}\n`
|
|
||||||
|
|
||||||
// Try to connect WebSocket immediately - cookies will be sent automatically
|
|
||||||
// Backend will authenticate via cookies and reject if invalid
|
|
||||||
if (!isConnected.value && !isConnecting.value) {
|
|
||||||
debugLog.value += 'Calling connect()...\n'
|
|
||||||
connect()
|
|
||||||
debugLog.value += 'connect() called\n'
|
|
||||||
} else {
|
|
||||||
debugLog.value += 'Skipped connect (already connected/connecting)\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check auth store (for display purposes)
|
|
||||||
authStore.checkAuth()
|
|
||||||
|
|
||||||
// Wait for connection, then join game
|
|
||||||
watch(isConnected, async (connected) => {
|
|
||||||
if (connected) {
|
|
||||||
connectionStatus.value = 'connected'
|
|
||||||
console.log('[Game Page] Connected - Joining game as player')
|
|
||||||
|
|
||||||
// Join game room
|
|
||||||
await actions.joinGame('player')
|
|
||||||
|
|
||||||
// Request current game state
|
|
||||||
await actions.requestGameState()
|
|
||||||
|
|
||||||
isLoading.value = false
|
|
||||||
} else {
|
|
||||||
connectionStatus.value = 'disconnected'
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Timeout fallback - if not connected after 5 seconds, stop loading
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isLoading.value) {
|
|
||||||
console.error('[Game Page] Connection timeout - stopping loading state')
|
|
||||||
isLoading.value = false
|
|
||||||
connectionStatus.value = 'disconnected'
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
|
|
||||||
// Measure ScoreBoard height after initial render
|
|
||||||
setTimeout(() => {
|
|
||||||
updateScoreBoardHeight()
|
|
||||||
}, 100)
|
|
||||||
|
|
||||||
// Update on window resize
|
|
||||||
if (import.meta.client) {
|
|
||||||
window.addEventListener('resize', updateScoreBoardHeight)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watch for game state to load lineups
|
|
||||||
watch(gameState, (state, oldState) => {
|
|
||||||
if (state && state.home_team_id && state.away_team_id) {
|
|
||||||
const oldBatter = oldState?.current_batter
|
|
||||||
const newBatter = state?.current_batter
|
|
||||||
const oldBatterInfo = oldBatter
|
|
||||||
? `lineup_id=${oldBatter.lineup_id}, batting_order=${oldBatter.batting_order}`
|
|
||||||
: 'None'
|
|
||||||
const newBatterInfo = newBatter
|
|
||||||
? `lineup_id=${newBatter.lineup_id}, batting_order=${newBatter.batting_order}`
|
|
||||||
: 'None'
|
|
||||||
console.log('[Game Page] gameState watch - current_batter:', oldBatterInfo, '->', newBatterInfo)
|
|
||||||
// Request lineup data for both teams to populate player names
|
|
||||||
console.log('[Game Page] Game state received - requesting lineups for teams:', state.home_team_id, state.away_team_id)
|
|
||||||
actions.getLineup(state.home_team_id)
|
|
||||||
actions.getLineup(state.away_team_id)
|
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Quality of Life: Auto-submit default decisions when bases are empty
|
// Handle lineup submission
|
||||||
watch([needsDefensiveDecision, needsOffensiveDecision, basesEmpty], ([defensive, offensive, empty]) => {
|
const handleLineupsSubmitted = (result: any) => {
|
||||||
// Only auto-submit if it's the player's turn and bases are empty
|
console.log('[Game Page] Lineups submitted:', result)
|
||||||
if (!isMyTurn.value || !empty) return
|
uiStore.showSuccess('Lineups submitted successfully!')
|
||||||
|
|
||||||
// Auto-submit defensive decision with defaults
|
// Switch to game tab after submitting lineups
|
||||||
if (defensive && !pendingDefensiveSetup.value) {
|
activeTab.value = 'game'
|
||||||
const defaultDefense: DefensiveDecision = {
|
}
|
||||||
infield_depth: 'normal',
|
|
||||||
outfield_depth: 'normal',
|
|
||||||
hold_runners: []
|
|
||||||
}
|
|
||||||
console.log('[Game Page] Bases empty - auto-submitting default defensive decision')
|
|
||||||
uiStore.showInfo('Bases empty - auto-submitting default defensive setup', 2000)
|
|
||||||
handleDefensiveSubmit(defaultDefense)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-submit offensive decision with swing away
|
|
||||||
if (offensive && !pendingOffensiveDecision.value) {
|
|
||||||
const defaultOffense = {
|
|
||||||
action: 'swing_away' as const
|
|
||||||
}
|
|
||||||
console.log('[Game Page] Bases empty - auto-submitting default offensive decision')
|
|
||||||
uiStore.showInfo('Bases empty - auto-submitting swing away', 2000)
|
|
||||||
handleOffensiveSubmit(defaultOffense)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
console.log('[Game Page] Unmounted - Leaving game')
|
|
||||||
|
|
||||||
// Leave game room
|
|
||||||
actions.leaveGame()
|
|
||||||
|
|
||||||
// Reset game store
|
|
||||||
gameStore.resetGame()
|
|
||||||
|
|
||||||
// Cleanup resize listener
|
|
||||||
if (import.meta.client) {
|
|
||||||
window.removeEventListener('resize', updateScoreBoardHeight)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watch for connection errors
|
|
||||||
watch(connectionError, (error) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('[Game Page] Connection error:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Additional styling if needed */
|
/* Tab content fills available space */
|
||||||
|
.tab-content {
|
||||||
|
min-height: calc(100vh - 128px); /* Subtract scoreboard + tab bar height */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const currentUser = computed(() => user.value)
|
const currentUser = computed(() => user.value)
|
||||||
const userTeams = computed(() => teams.value)
|
const userTeams = computed(() => teams.value)
|
||||||
|
const userTeamIds = computed(() => teams.value.map(t => t.id))
|
||||||
const userId = computed(() => user.value?.id ?? null)
|
const userId = computed(() => user.value?.id ?? null)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -160,6 +161,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
currentUser,
|
currentUser,
|
||||||
userTeams,
|
userTeams,
|
||||||
|
userTeamIds,
|
||||||
userId,
|
userId,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user