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:
Cal Corum 2026-01-16 14:08:39 -06:00
parent 5562d8de36
commit 1f5e290d8b
6 changed files with 1609 additions and 884 deletions

View File

@ -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)}"
)

View 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>

View 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>

View File

@ -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">&larr;</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>

View File

@ -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>

View File

@ -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