When WebSocket connection fails after max attempts (permanentlyFailed state): - Show red error banner with "Connection Failed" message and "Try Again" button - Loading modal distinguishes between connecting/reconnecting/failed states - "Try Again" button uses manualRetry() to reset state and attempt fresh connection - Yellow reconnecting banner only shows during active reconnection attempts Uses permanentlyFailed state and manualRetry() from HIGH-002. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
877 lines
33 KiB
Vue
Executable File
877 lines
33 KiB
Vue
Executable File
<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">
|
|
<ScoreBoard
|
|
:home-score="gameState?.home_score"
|
|
:away-score="gameState?.away_score"
|
|
:inning="gameState?.inning"
|
|
:half="gameState?.half"
|
|
: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>
|
|
<button
|
|
@click="manualRetry"
|
|
class="ml-4 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition flex-shrink-0"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Connection Status Banner (Reconnecting) -->
|
|
<div
|
|
v-else-if="!isConnected"
|
|
class="mb-4 bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<svg class="h-5 w-5 text-yellow-400 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm text-yellow-700">
|
|
{{ connectionStatus === 'connecting' ? 'Connecting to game server...' : 'Disconnected from server. Attempting to reconnect...' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="forceReconnect"
|
|
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-700 bg-yellow-100 hover:bg-yellow-200 rounded-md transition"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Layout (Stacked) -->
|
|
<div class="lg:hidden space-y-6">
|
|
<!-- Current Situation -->
|
|
<CurrentSituation
|
|
:current-batter="gameState?.current_batter"
|
|
:current-pitcher="gameState?.current_pitcher"
|
|
/>
|
|
|
|
<!-- Game Board -->
|
|
<GameBoard
|
|
:runners="runnersState"
|
|
:current-batter="gameState?.current_batter"
|
|
:current-pitcher="gameState?.current_pitcher"
|
|
/>
|
|
|
|
<!-- Play-by-Play Feed -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-md">
|
|
<PlayByPlay
|
|
:plays="playHistory"
|
|
:limit="5"
|
|
:compact="true"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Decision Panel (Phase F3) -->
|
|
<DecisionPanel
|
|
v-if="showDecisions"
|
|
:game-id="gameId"
|
|
:current-team="currentTeam"
|
|
:is-my-turn="isMyTurn"
|
|
:phase="decisionPhase"
|
|
:runners="runnersData"
|
|
:current-defensive-setup="pendingDefensiveSetup ?? undefined"
|
|
:current-offensive-decision="pendingOffensiveDecision ?? undefined"
|
|
:current-steal-attempts="pendingStealAttempts"
|
|
:decision-history="decisionHistory"
|
|
@defensive-submit="handleDefensiveSubmit"
|
|
@offensive-submit="handleOffensiveSubmit"
|
|
@steal-attempts-submit="handleStealAttemptsSubmit"
|
|
/>
|
|
|
|
<!-- Gameplay Panel (Phase F4) -->
|
|
<GameplayPanel
|
|
v-if="showGameplay"
|
|
:game-id="gameId"
|
|
:is-my-turn="isMyTurn"
|
|
:can-roll-dice="canRollDice"
|
|
:pending-roll="pendingRoll"
|
|
:last-play-result="lastPlayResult"
|
|
:can-submit-outcome="canSubmitOutcome"
|
|
:outs="gameState?.outs ?? 0"
|
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
|
@roll-dice="handleRollDice"
|
|
@submit-outcome="handleSubmitOutcome"
|
|
@dismiss-result="handleDismissResult"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Desktop Layout (Grid) -->
|
|
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
|
|
<!-- Left Column: Game State -->
|
|
<div class="lg:col-span-2 space-y-6">
|
|
<!-- Current Situation -->
|
|
<CurrentSituation
|
|
:current-batter="gameState?.current_batter"
|
|
:current-pitcher="gameState?.current_pitcher"
|
|
/>
|
|
|
|
<!-- Game Board -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
|
|
<GameBoard
|
|
:runners="runnersState"
|
|
:current-batter="gameState?.current_batter"
|
|
:current-pitcher="gameState?.current_pitcher"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Decision Panel (Phase F3) -->
|
|
<DecisionPanel
|
|
v-if="showDecisions"
|
|
:game-id="gameId"
|
|
:current-team="currentTeam"
|
|
:is-my-turn="isMyTurn"
|
|
:phase="decisionPhase"
|
|
:runners="runnersData"
|
|
:current-defensive-setup="pendingDefensiveSetup ?? undefined"
|
|
:current-offensive-decision="pendingOffensiveDecision ?? undefined"
|
|
:current-steal-attempts="pendingStealAttempts"
|
|
:decision-history="decisionHistory"
|
|
@defensive-submit="handleDefensiveSubmit"
|
|
@offensive-submit="handleOffensiveSubmit"
|
|
@steal-attempts-submit="handleStealAttemptsSubmit"
|
|
/>
|
|
|
|
<!-- Gameplay Panel (Phase F4) -->
|
|
<GameplayPanel
|
|
v-if="showGameplay"
|
|
:game-id="gameId"
|
|
:is-my-turn="isMyTurn"
|
|
:can-roll-dice="canRollDice"
|
|
:pending-roll="pendingRoll"
|
|
:last-play-result="lastPlayResult"
|
|
:can-submit-outcome="canSubmitOutcome"
|
|
:outs="gameState?.outs ?? 0"
|
|
:has-runners="!!(gameState?.on_first || gameState?.on_second || gameState?.on_third)"
|
|
@roll-dice="handleRollDice"
|
|
@submit-outcome="handleSubmitOutcome"
|
|
@dismiss-result="handleDismissResult"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Right Column: Play-by-Play -->
|
|
<div class="lg:col-span-1">
|
|
<div
|
|
class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg sticky"
|
|
:style="{ top: `${scoreBoardHeight + 16}px` }"
|
|
>
|
|
<PlayByPlay
|
|
:plays="playHistory"
|
|
:scrollable="true"
|
|
:max-height="600"
|
|
:show-filters="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
>
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-2xl text-center max-w-sm mx-4">
|
|
<!-- Show spinner only if actively connecting -->
|
|
<div v-if="isConnecting" class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"/>
|
|
|
|
<!-- Show error state if not connecting -->
|
|
<div v-else class="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
|
|
<p class="text-gray-900 dark:text-white font-semibold">
|
|
{{ isConnecting ? 'Connecting to game...' : permanentlyFailed ? 'Connection Failed' : 'Reconnecting...' }}
|
|
</p>
|
|
<p v-if="permanentlyFailed" class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
Unable to reach server after multiple attempts
|
|
</p>
|
|
|
|
<!-- Status info -->
|
|
<div class="mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-left">
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<span :class="authStore.isAuthenticated ? 'text-green-600' : authStore.isLoading ? 'text-yellow-600' : 'text-red-600'">●</span>
|
|
<span class="text-gray-700 dark:text-gray-300">Auth: {{ authStore.isAuthenticated ? 'OK' : authStore.isLoading ? 'Checking...' : 'Failed' }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-sm mt-1">
|
|
<span :class="isConnected ? 'text-green-600' : isConnecting ? 'text-yellow-600' : permanentlyFailed ? 'text-red-600' : 'text-orange-500'">●</span>
|
|
<span class="text-gray-700 dark:text-gray-300">WebSocket: {{ isConnected ? 'Connected' : isConnecting ? 'Connecting...' : permanentlyFailed ? 'Failed' : 'Reconnecting...' }}</span>
|
|
</div>
|
|
<p v-if="connectionError" class="text-xs text-red-600 mt-2">{{ connectionError }}</p>
|
|
<!-- Debug info -->
|
|
<div class="mt-2 text-xs text-gray-500 border-t pt-2">
|
|
<p>WS URL: {{ wsDebugUrl }}</p>
|
|
<p>Socket exists: {{ socketExists }}</p>
|
|
<p class="mt-1 font-mono text-[10px] max-h-24 overflow-y-auto">{{ debugLog }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action buttons -->
|
|
<div class="mt-4 flex flex-col gap-2">
|
|
<button
|
|
@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('/games')"
|
|
>
|
|
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 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'
|
|
|
|
definePageMeta({
|
|
layout: 'game',
|
|
middleware: ['auth'],
|
|
})
|
|
|
|
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
|
|
const gameId = computed(() => route.params.id as string)
|
|
|
|
// 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 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)
|
|
|
|
// 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 (if any) the current user owns in this game
|
|
const myTeamId = computed(() => {
|
|
if (!gameState.value) return null
|
|
|
|
const userTeamIds = authStore.userTeams.map(t => t.id)
|
|
|
|
if (userTeamIds.includes(gameState.value.home_team_id)) {
|
|
return gameState.value.home_team_id
|
|
}
|
|
if (userTeamIds.includes(gameState.value.away_team_id)) {
|
|
return gameState.value.away_team_id
|
|
}
|
|
|
|
return null // Spectator - doesn't own either team
|
|
})
|
|
|
|
// Dynamic ScoreBoard height tracking
|
|
const scoreBoardRef = ref<HTMLElement | null>(null)
|
|
const scoreBoardHeight = ref(0)
|
|
|
|
// 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'
|
|
})
|
|
|
|
const isMyTurn = computed(() => {
|
|
if (!myTeamId.value || !gameState.value) return false
|
|
|
|
// Determine which team needs to act based on decision phase
|
|
if (needsDefensiveDecision.value) {
|
|
// Fielding team makes defensive decisions
|
|
// Top of inning: home fields, Bottom: away fields
|
|
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
|
|
// Top of inning: away bats, Bottom: home bats
|
|
const battingTeamId = gameState.value.half === 'top'
|
|
? gameState.value.away_team_id
|
|
: gameState.value.home_team_id
|
|
return myTeamId.value === battingTeamId
|
|
}
|
|
|
|
return false
|
|
})
|
|
|
|
const decisionPhase = computed(() => {
|
|
if (needsDefensiveDecision.value) return 'defensive'
|
|
if (needsOffensiveDecision.value) return 'offensive'
|
|
return 'idle'
|
|
})
|
|
|
|
// Phase F6: Conditional panel rendering
|
|
const showDecisions = computed(() => {
|
|
// Don't show decision panels if there's a result pending dismissal
|
|
if (lastPlayResult.value) {
|
|
return false
|
|
}
|
|
|
|
const result = gameState.value?.status === 'active' &&
|
|
isMyTurn.value &&
|
|
(needsDefensiveDecision.value || needsOffensiveDecision.value)
|
|
|
|
// Debug logging
|
|
console.log('[Game Page] Panel visibility check:', {
|
|
gameStatus: gameState.value?.status,
|
|
isMyTurn: isMyTurn.value,
|
|
needsDefensiveDecision: needsDefensiveDecision.value,
|
|
needsOffensiveDecision: needsOffensiveDecision.value,
|
|
decision_phase: gameState.value?.decision_phase,
|
|
showDecisions: result,
|
|
currentDecisionPrompt: currentDecisionPrompt.value,
|
|
hasLastPlayResult: !!lastPlayResult.value
|
|
})
|
|
|
|
return result
|
|
})
|
|
|
|
const showGameplay = computed(() => {
|
|
// Show gameplay panel if there's a result to display OR if we're in the resolution phase
|
|
if (lastPlayResult.value) {
|
|
return true
|
|
}
|
|
|
|
return gameState.value?.status === 'active' &&
|
|
isMyTurn.value &&
|
|
!needsDefensiveDecision.value &&
|
|
!needsOffensiveDecision.value
|
|
})
|
|
|
|
const canMakeSubstitutions = computed(() => {
|
|
return gameState.value?.status === 'active' && isMyTurn.value
|
|
})
|
|
|
|
const canUndo = computed(() => {
|
|
// Can only undo if game is active and there are plays to undo
|
|
return gameState.value?.status === 'active' && (gameState.value?.play_count ?? 0) > 0
|
|
})
|
|
|
|
// Lineup helpers for substitutions
|
|
const currentLineup = computed(() => {
|
|
if (!myTeamId.value) return []
|
|
return myTeamId.value === gameState.value?.home_team_id
|
|
? gameStore.homeLineup.filter(l => l.is_active)
|
|
: gameStore.awayLineup.filter(l => l.is_active)
|
|
})
|
|
|
|
const benchPlayers = computed(() => {
|
|
if (!myTeamId.value) return []
|
|
return myTeamId.value === gameState.value?.home_team_id
|
|
? gameStore.homeLineup.filter(l => !l.is_active)
|
|
: gameStore.awayLineup.filter(l => !l.is_active)
|
|
})
|
|
|
|
const currentBatter = computed(() => {
|
|
const batterState = gameState.value?.current_batter
|
|
if (!batterState) return null
|
|
return gameStore.findPlayerInLineup(batterState.lineup_id)
|
|
})
|
|
|
|
const currentPitcher = computed(() => {
|
|
const pitcherState = gameState.value?.current_pitcher
|
|
if (!pitcherState) return null
|
|
return gameStore.findPlayerInLineup(pitcherState.lineup_id)
|
|
})
|
|
|
|
// Methods - Gameplay (Phase F4)
|
|
const handleRollDice = async () => {
|
|
console.log('[Game Page] Rolling dice')
|
|
try {
|
|
await actions.rollDice()
|
|
// The dice_rolled event will update pendingRoll via WebSocket
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to roll dice:', error)
|
|
}
|
|
}
|
|
|
|
const handleSubmitOutcome = async (data: { outcome: PlayOutcome; hitLocation?: string }) => {
|
|
console.log('[Game Page] Submitting outcome:', data)
|
|
try {
|
|
await actions.submitManualOutcome(
|
|
data.outcome,
|
|
data.hitLocation
|
|
)
|
|
// Pending roll will be cleared by backend after successful submission
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to submit outcome:', error)
|
|
}
|
|
}
|
|
|
|
const handleDismissResult = () => {
|
|
console.log('[Game Page] Dismissing result')
|
|
gameStore.clearLastPlayResult()
|
|
}
|
|
|
|
const handleDefensiveSubmit = async (decision: DefensiveDecision) => {
|
|
console.log('[Game Page] Submitting defensive decision:', decision)
|
|
try {
|
|
await actions.submitDefensiveDecision(decision)
|
|
gameStore.setPendingDefensiveSetup(decision)
|
|
gameStore.addDecisionToHistory('Defensive', `${decision.infield_depth} infield, ${decision.outfield_depth} outfield`)
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to submit defensive decision:', error)
|
|
}
|
|
}
|
|
|
|
const handleOffensiveSubmit = async (decision: Omit<OffensiveDecision, 'steal_attempts'>) => {
|
|
console.log('[Game Page] Submitting offensive decision:', decision)
|
|
try {
|
|
// Combine with steal attempts
|
|
const fullDecision: OffensiveDecision = {
|
|
...decision,
|
|
steal_attempts: pendingStealAttempts.value,
|
|
}
|
|
await actions.submitOffensiveDecision(fullDecision)
|
|
gameStore.setPendingOffensiveDecision(decision)
|
|
const actionLabels: Record<string, string> = {
|
|
swing_away: 'Swing Away',
|
|
steal: 'Steal',
|
|
check_jump: 'Check Jump',
|
|
hit_and_run: 'Hit & Run',
|
|
sac_bunt: 'Sac Bunt',
|
|
squeeze_bunt: 'Squeeze Bunt',
|
|
}
|
|
gameStore.addDecisionToHistory('Offensive', actionLabels[decision.action] || decision.action)
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to submit offensive decision:', error)
|
|
}
|
|
}
|
|
|
|
const handleStealAttemptsSubmit = (attempts: number[]) => {
|
|
console.log('[Game Page] Updating steal attempts:', attempts)
|
|
gameStore.setPendingStealAttempts(attempts)
|
|
}
|
|
|
|
// Methods - Substitutions (Phase F5)
|
|
const handlePinchHitter = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
console.log('[Game Page] Submitting pinch hitter:', data)
|
|
try {
|
|
await actions.submitSubstitution(
|
|
'pinch_hitter',
|
|
data.playerOutLineupId,
|
|
data.playerInCardId,
|
|
data.teamId
|
|
)
|
|
showSubstitutions.value = false
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to submit pinch hitter:', error)
|
|
}
|
|
}
|
|
|
|
const handleDefensiveReplacement = async (data: { playerOutLineupId: number; playerInCardId: number; newPosition: string; teamId: number }) => {
|
|
console.log('[Game Page] Submitting defensive replacement:', data)
|
|
try {
|
|
await actions.submitSubstitution(
|
|
'defensive_replacement',
|
|
data.playerOutLineupId,
|
|
data.playerInCardId,
|
|
data.teamId,
|
|
data.newPosition
|
|
)
|
|
showSubstitutions.value = false
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to submit defensive replacement:', error)
|
|
}
|
|
}
|
|
|
|
const handlePitchingChange = async (data: { playerOutLineupId: number; playerInCardId: number; teamId: number }) => {
|
|
console.log('[Game Page] Submitting pitching change:', data)
|
|
try {
|
|
await actions.submitSubstitution(
|
|
'pitching_change',
|
|
data.playerOutLineupId,
|
|
data.playerInCardId,
|
|
data.teamId
|
|
)
|
|
showSubstitutions.value = false
|
|
} catch (error) {
|
|
console.error('[Game Page] Failed to submit pitching change:', error)
|
|
}
|
|
}
|
|
|
|
const handleSubstitutionCancel = () => {
|
|
console.log('[Game Page] Cancelling substitution')
|
|
showSubstitutions.value = false
|
|
}
|
|
|
|
// Undo handler
|
|
const handleUndoLastPlay = () => {
|
|
console.log('[Game Page] Undoing last play')
|
|
undoLastPlay(1)
|
|
}
|
|
|
|
// Retry connection with auth check
|
|
const retryWithAuth = async () => {
|
|
console.log('[Game Page] Retry with auth check')
|
|
isLoading.value = true
|
|
connectionStatus.value = 'connecting'
|
|
|
|
// First check auth
|
|
const isAuthed = await authStore.checkAuth()
|
|
console.log('[Game Page] Auth result:', isAuthed, 'isAuthenticated:', authStore.isAuthenticated)
|
|
|
|
if (!isAuthed) {
|
|
console.error('[Game Page] Auth failed')
|
|
isLoading.value = false
|
|
connectionStatus.value = 'disconnected'
|
|
return
|
|
}
|
|
|
|
// Force reconnect
|
|
forceReconnect()
|
|
}
|
|
|
|
// Measure ScoreBoard height dynamically
|
|
const updateScoreBoardHeight = () => {
|
|
if (scoreBoardRef.value) {
|
|
scoreBoardHeight.value = scoreBoardRef.value.offsetHeight
|
|
console.log('[Game Page] ScoreBoard height:', scoreBoardHeight.value)
|
|
}
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
// Debug logging for Safari troubleshooting
|
|
debugLog.value = `Mounted at ${new Date().toLocaleTimeString()}\n`
|
|
debugLog.value += `isConnected: ${isConnected.value}, isConnecting: ${isConnecting.value}\n`
|
|
|
|
// Try to connect WebSocket immediately - cookies will be sent automatically
|
|
// Backend will authenticate via cookies and reject if invalid
|
|
if (!isConnected.value && !isConnecting.value) {
|
|
debugLog.value += 'Calling connect()...\n'
|
|
connect()
|
|
debugLog.value += 'connect() called\n'
|
|
} else {
|
|
debugLog.value += 'Skipped connect (already connected/connecting)\n'
|
|
}
|
|
|
|
// Also check auth store (for display purposes)
|
|
authStore.checkAuth()
|
|
|
|
// Wait for connection, then join game
|
|
watch(isConnected, async (connected) => {
|
|
if (connected) {
|
|
connectionStatus.value = 'connected'
|
|
console.log('[Game Page] Connected - Joining game as player')
|
|
|
|
// Join game room
|
|
await actions.joinGame('player')
|
|
|
|
// Request current game state
|
|
await actions.requestGameState()
|
|
|
|
isLoading.value = false
|
|
} else {
|
|
connectionStatus.value = 'disconnected'
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Timeout fallback - if not connected after 5 seconds, stop loading
|
|
setTimeout(() => {
|
|
if (isLoading.value) {
|
|
console.error('[Game Page] Connection timeout - stopping loading state')
|
|
isLoading.value = false
|
|
connectionStatus.value = 'disconnected'
|
|
}
|
|
}, 5000)
|
|
|
|
// Measure ScoreBoard height after initial render
|
|
setTimeout(() => {
|
|
updateScoreBoardHeight()
|
|
}, 100)
|
|
|
|
// Update on window resize
|
|
if (import.meta.client) {
|
|
window.addEventListener('resize', updateScoreBoardHeight)
|
|
}
|
|
})
|
|
|
|
// Watch for game state to load lineups
|
|
watch(gameState, (state, oldState) => {
|
|
if (state && state.home_team_id && state.away_team_id) {
|
|
const oldBatter = oldState?.current_batter
|
|
const newBatter = state?.current_batter
|
|
const oldBatterInfo = oldBatter
|
|
? `lineup_id=${oldBatter.lineup_id}, batting_order=${oldBatter.batting_order}`
|
|
: 'None'
|
|
const newBatterInfo = newBatter
|
|
? `lineup_id=${newBatter.lineup_id}, batting_order=${newBatter.batting_order}`
|
|
: 'None'
|
|
console.log('[Game Page] gameState watch - current_batter:', oldBatterInfo, '->', newBatterInfo)
|
|
// Request lineup data for both teams to populate player names
|
|
console.log('[Game Page] Game state received - requesting lineups for teams:', state.home_team_id, state.away_team_id)
|
|
actions.getLineup(state.home_team_id)
|
|
actions.getLineup(state.away_team_id)
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// 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 */
|
|
</style>
|