strat-gameplay-webapp/frontend-sba/pages/games/[id].vue
Cal Corum 38fb76c849 CLAUDE: Fix resolution phase control and add demo mode
Bug fix: During resolution phase (dice rolling), isMyTurn was false
for both players, preventing anyone from seeing the dice roller.
Now the batting team has control during resolution since they read
their card.

Demo mode: myTeamId now returns whichever team needs to act,
allowing single-player testing of both sides.

Changes:
- Add creator_discord_id to GameState (backend + frontend types)
- Add get_current_user_optional dependency for optional auth
- Update quick-create to capture creator's discord_id
- Fix isMyTurn to give batting team control during resolution
- Demo mode: myTeamId returns active team based on phase

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:47:21 -06:00

884 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('/')"
>
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 the user controls
// For demo/testing: user controls whichever team needs to act
const myTeamId = computed(() => {
if (!gameState.value) return null
// Return the team that currently needs to make a decision
if (gameState.value.half === 'top') {
// Top: away bats, home fields
return gameState.value.decision_phase === 'awaiting_defensive'
? gameState.value.home_team_id
: gameState.value.away_team_id
} else {
// Bottom: home bats, away fields
return gameState.value.decision_phase === 'awaiting_defensive'
? gameState.value.away_team_id
: gameState.value.home_team_id
}
})
// Dynamic ScoreBoard height tracking
const scoreBoardRef = ref<HTMLElement | null>(null)
const scoreBoardHeight = ref(0)
// Computed helpers
const runnersState = computed(() => {
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('[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>