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,12 +258,9 @@ async def get_game(game_id: str):
|
||||
|
||||
logger.info(f"Fetching game details for {game_id}")
|
||||
|
||||
# Get game from state manager
|
||||
if game_uuid not in state_manager._states:
|
||||
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||
|
||||
state = state_manager._states[game_uuid]
|
||||
|
||||
# Try to get game from state manager first (in-memory)
|
||||
state = state_manager.get_state(game_uuid)
|
||||
if state:
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"status": state.status,
|
||||
@ -272,6 +269,29 @@ async def get_game(game_id: str):
|
||||
"league_id": state.league_id,
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(Game).where(Game.id == game_uuid)
|
||||
)
|
||||
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:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -628,31 +648,18 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
||||
|
||||
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)
|
||||
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
|
||||
db_ops = DatabaseOperations()
|
||||
game_data = await db_ops.load_game_state(game_uuid)
|
||||
# Use recover_game to properly load game state
|
||||
state = await state_manager.recover_game(game_uuid)
|
||||
|
||||
if not game_data:
|
||||
if not state:
|
||||
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||
|
||||
game_info = game_data['game']
|
||||
|
||||
# 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")
|
||||
logger.info(f"Recovered game {game_id} from database")
|
||||
|
||||
# Process home team lineup
|
||||
home_count = 0
|
||||
@ -708,3 +715,331 @@ async def submit_lineups(game_id: str, request: SubmitLineupsRequest):
|
||||
raise HTTPException(
|
||||
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">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { SbaPlayer, LineupPlayerRequest, SubmitLineupsRequest } from '~/types'
|
||||
import ActionButton from '~/components/UI/ActionButton.vue'
|
||||
|
||||
// Use no layout - this page has its own complete UI
|
||||
definePageMeta({ layout: false })
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
gameId: string
|
||||
teamId?: number | null // Optional: if provided, user manages this team
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Game and team data
|
||||
const gameId = ref(route.params.id as string)
|
||||
const homeTeamId = ref<number | null>(null)
|
||||
const awayTeamId = ref<number | null>(null)
|
||||
const season = ref(3)
|
||||
@ -36,6 +36,12 @@ const awayRoster = ref<SbaPlayer[]>([])
|
||||
const loadingRoster = 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)
|
||||
interface LineupSlot {
|
||||
player: SbaPlayer | null
|
||||
@ -95,14 +101,16 @@ const duplicatePositions = computed(() => {
|
||||
.map(([pos, _]) => pos)
|
||||
})
|
||||
|
||||
// Per-team validation
|
||||
const homeValidationErrors = computed(() => validateLineup(homeLineup.value, 'Home'))
|
||||
const awayValidationErrors = computed(() => validateLineup(awayLineup.value, 'Away'))
|
||||
|
||||
const validationErrors = computed(() => {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check both lineups
|
||||
const homeErrors = validateLineup(homeLineup.value, 'Home')
|
||||
const awayErrors = validateLineup(awayLineup.value, 'Away')
|
||||
|
||||
return [...homeErrors, ...awayErrors]
|
||||
// Show errors for active tab only
|
||||
if (activeTab.value === 'home') {
|
||||
return homeValidationErrors.value
|
||||
}
|
||||
return awayValidationErrors.value
|
||||
})
|
||||
|
||||
function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
|
||||
@ -143,8 +151,28 @@ function validateLineup(lineup: LineupSlot[], teamName: string): string[] {
|
||||
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(() => {
|
||||
return validationErrors.value.length === 0
|
||||
return homeValidationErrors.value.length === 0 && awayValidationErrors.value.length === 0
|
||||
})
|
||||
|
||||
// Get player's available positions
|
||||
@ -287,7 +315,9 @@ function getPlayerFallbackInitial(player: SbaPlayer): string {
|
||||
// Fetch game data
|
||||
async function fetchGameData() {
|
||||
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()
|
||||
homeTeamId.value = data.home_team_id
|
||||
awayTeamId.value = data.away_team_id
|
||||
@ -300,7 +330,9 @@ async function fetchGameData() {
|
||||
async function fetchRoster(teamId: number) {
|
||||
try {
|
||||
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()
|
||||
return data.players as SbaPlayer[]
|
||||
} 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() {
|
||||
if (!canSubmit.value || submittingLineups.value) return
|
||||
|
||||
@ -342,10 +452,11 @@ async function submitLineups() {
|
||||
console.log('Submitting lineup request:', JSON.stringify(request, null, 2))
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request)
|
||||
body: JSON.stringify(request),
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@ -356,7 +467,7 @@ async function submitLineups() {
|
||||
if (error.detail && Array.isArray(error.detail)) {
|
||||
const messages = error.detail.map((err: any) => {
|
||||
if (err.loc) {
|
||||
const location = err.loc.join(' → ')
|
||||
const location = err.loc.join(' -> ')
|
||||
return `${location}: ${err.msg}`
|
||||
}
|
||||
return err.msg || JSON.stringify(err)
|
||||
@ -370,8 +481,8 @@ async function submitLineups() {
|
||||
const result = await response.json()
|
||||
console.log('Lineups submitted:', result)
|
||||
|
||||
// Redirect to game page
|
||||
router.push(`/games/${gameId.value}`)
|
||||
// Emit event instead of navigating (parent component handles navigation)
|
||||
emit('lineups-submitted', result)
|
||||
} catch (error) {
|
||||
console.error('Failed to submit lineups:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to submit lineups')
|
||||
@ -380,39 +491,126 @@ async function submitLineups() {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit events
|
||||
const emit = defineEmits<{
|
||||
'lineups-submitted': [result: any]
|
||||
}>()
|
||||
|
||||
// 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 () => {
|
||||
await fetchGameData()
|
||||
|
||||
// Check if lineups already submitted (also gets lineup data if available)
|
||||
await checkLineupStatus()
|
||||
|
||||
// Fetch rosters
|
||||
if (homeTeamId.value) {
|
||||
homeRoster.value = await fetchRoster(homeTeamId.value)
|
||||
}
|
||||
if (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>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-b from-gray-900 via-gray-900 to-gray-950 text-white">
|
||||
<!-- 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="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink
|
||||
:to="`/games/${gameId}`"
|
||||
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 class="text-sm text-gray-400">
|
||||
Game #{{ gameId.slice(0, 8) }}
|
||||
</div>
|
||||
@ -737,7 +935,7 @@ onMounted(async () => {
|
||||
</option>
|
||||
</select>
|
||||
<div v-else class="text-gray-600 text-xs text-center">
|
||||
—
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -857,18 +1055,53 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit button (per-team) -->
|
||||
<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 -->
|
||||
<div class="sticky bottom-4 pt-4">
|
||||
<ActionButton
|
||||
v-else
|
||||
variant="success"
|
||||
size="lg"
|
||||
full-width
|
||||
:disabled="!canSubmit"
|
||||
:loading="submittingLineups"
|
||||
@click="submitLineups"
|
||||
:disabled="!canSubmitActiveTeam"
|
||||
:loading="isSubmittingActiveTeam"
|
||||
@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>
|
||||
|
||||
<!-- 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>
|
||||
@ -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"
|
||||
@click="closePlayerPreview"
|
||||
>
|
||||
×
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -937,7 +1170,7 @@ onMounted(async () => {
|
||||
<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-xl font-bold">
|
||||
{{ previewPlayer.wara?.toFixed(1) || '—' }}
|
||||
{{ previewPlayer.wara?.toFixed(1) || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Sticky ScoreBoard Header -->
|
||||
<div ref="scoreBoardRef" class="sticky top-0 z-20">
|
||||
<!-- Sticky Header: ScoreBoard + Tabs -->
|
||||
<div class="sticky top-0 z-30">
|
||||
<!-- ScoreBoard -->
|
||||
<ScoreBoard
|
||||
:home-score="gameState?.home_score"
|
||||
:away-score="gameState?.away_score"
|
||||
@ -10,353 +11,55 @@
|
||||
:outs="gameState?.outs"
|
||||
:runners="runnersState"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Tab Navigation -->
|
||||
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex">
|
||||
<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"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
: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"
|
||||
>
|
||||
Try Again
|
||||
{{ tab.label }}
|
||||
<!-- Active indicator -->
|
||||
<span
|
||||
v-if="activeTab === tab.id"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary dark:bg-blue-400"
|
||||
/>
|
||||
</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"
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- Game Tab (use v-show to keep WebSocket connected) -->
|
||||
<GamePlay
|
||||
v-show="activeTab === 'game'"
|
||||
: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"
|
||||
<!-- Lineups Tab (use v-show to preserve state when switching tabs) -->
|
||||
<div v-show="activeTab === 'lineups'" class="container mx-auto px-4 py-6">
|
||||
<LineupBuilder
|
||||
: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"
|
||||
:team-id="myManagedTeamId"
|
||||
@lineups-submitted="handleLineupsSubmitted"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<!-- Stats Tab -->
|
||||
<div v-show="activeTab === 'stats'" class="container mx-auto px-4 py-6">
|
||||
<GameStats :game-id="gameId" />
|
||||
</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
|
||||
@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($route.fullPath))"
|
||||
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>
|
||||
@ -365,103 +68,41 @@
|
||||
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 ScoreBoard from '~/components/Game/ScoreBoard.vue'
|
||||
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, RollData, PlayResult } from '~/types/game'
|
||||
import type { Lineup } from '~/types/player'
|
||||
import GamePlay from '~/components/Game/GamePlay.vue'
|
||||
import LineupBuilder from '~/components/Game/LineupBuilder.vue'
|
||||
import GameStats from '~/components/Game/GameStats.vue'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'game',
|
||||
middleware: ['auth'],
|
||||
})
|
||||
|
||||
// Stores
|
||||
const route = useRoute()
|
||||
const gameStore = useGameStore()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
// Auth is initialized by the auth plugin automatically
|
||||
|
||||
// Get game ID from route
|
||||
// Game ID from route
|
||||
const gameId = computed(() => route.params.id as string)
|
||||
|
||||
// WebSocket connection
|
||||
const { socket, isConnected, isConnecting, connectionError, permanentlyFailed, connect, forceReconnect, manualRetry } = useWebSocket()
|
||||
// Tab state
|
||||
type GameTab = 'game' | 'lineups' | 'stats'
|
||||
const activeTab = ref<GameTab>('game')
|
||||
const defaultTabSet = ref(false)
|
||||
|
||||
// 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...')
|
||||
// Tab definitions
|
||||
const tabs = [
|
||||
{ id: 'game' as const, label: 'Game' },
|
||||
{ id: 'lineups' as const, label: 'Lineups' },
|
||||
{ id: 'stats' as const, label: 'Stats' },
|
||||
]
|
||||
|
||||
// Pass the raw string value from route params, not computed value
|
||||
// useGameActions will create its own computed internally if needed
|
||||
const actions = useGameActions(route.params.id as string)
|
||||
// Game state from store (populated by GamePlay component via WebSocket)
|
||||
const gameState = computed(() => gameStore.gameState)
|
||||
|
||||
// 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('[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
|
||||
// Runners state for ScoreBoard
|
||||
const runnersState = computed(() => {
|
||||
if (!gameState.value) {
|
||||
return { first: false, second: false, third: false }
|
||||
@ -474,410 +115,68 @@ const runnersState = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const runnersData = computed(() => {
|
||||
return {
|
||||
first: gameState.value?.on_first ?? null,
|
||||
second: gameState.value?.on_second ?? null,
|
||||
third: gameState.value?.on_third ?? null,
|
||||
}
|
||||
// Check if user is a manager of either team in this game
|
||||
const isUserManager = computed(() => {
|
||||
if (!gameState.value) return false
|
||||
const userTeams = authStore.userTeamIds
|
||||
|
||||
return userTeams.includes(gameState.value.home_team_id) ||
|
||||
userTeams.includes(gameState.value.away_team_id)
|
||||
})
|
||||
|
||||
const currentTeam = computed(() => {
|
||||
return gameState.value?.half === 'top' ? 'away' : 'home'
|
||||
// Determine which team the user manages (if any)
|
||||
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
|
||||
const isMyTurn = computed(() => {
|
||||
if (!myTeamId.value || !gameState.value) return false
|
||||
// Set default tab based on game status and user role
|
||||
watch(gameState, (state) => {
|
||||
if (!state) return
|
||||
|
||||
// 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
|
||||
// Only set default once (don't override user navigation)
|
||||
if (defaultTabSet.value) return
|
||||
defaultTabSet.value = true
|
||||
|
||||
// Completed games → Stats tab
|
||||
if (state.status === 'completed') {
|
||||
console.log('[Game Page] Game completed - defaulting to Stats tab')
|
||||
activeTab.value = 'stats'
|
||||
}
|
||||
|
||||
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
|
||||
// Pending games + manager → Lineups tab
|
||||
else if (state.status === 'pending' && isUserManager.value) {
|
||||
console.log('[Game Page] Pending game + manager - defaulting to Lineups tab')
|
||||
activeTab.value = 'lineups'
|
||||
}
|
||||
|
||||
// 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('[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'
|
||||
// All other cases → Game tab (already default)
|
||||
else {
|
||||
console.log('[Game Page] Defaulting to Game tab (status:', state.status, ', isManager:', isUserManager.value, ')')
|
||||
}
|
||||
}, { 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'
|
||||
// Handle lineup submission
|
||||
const handleLineupsSubmitted = (result: any) => {
|
||||
console.log('[Game Page] Lineups submitted:', result)
|
||||
uiStore.showSuccess('Lineups submitted successfully!')
|
||||
|
||||
// Switch to game tab after submitting lineups
|
||||
activeTab.value = 'game'
|
||||
}
|
||||
}, 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 })
|
||||
|
||||
// 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('[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>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styling if needed */
|
||||
/* Tab content fills available space */
|
||||
.tab-content {
|
||||
min-height: calc(100vh - 128px); /* Subtract scoreboard + tab bar height */
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -29,6 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const currentUser = computed(() => user.value)
|
||||
const userTeams = computed(() => teams.value)
|
||||
const userTeamIds = computed(() => teams.value.map(t => t.id))
|
||||
const userId = computed(() => user.value?.id ?? null)
|
||||
|
||||
// ============================================================================
|
||||
@ -160,6 +161,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
userTeams,
|
||||
userTeamIds,
|
||||
userId,
|
||||
|
||||
// Actions
|
||||
|
||||
Loading…
Reference in New Issue
Block a user