Created centralized services for SBA player data fetching at lineup creation: Backend - New Services: - app/services/sba_api_client.py: REST client for SBA API (api.sba.manticorum.com) with batch player fetching and caching support - app/services/lineup_service.py: High-level service combining DB operations with API calls for complete lineup entries with player data Backend - Refactored Components: - app/core/game_engine.py: Replaced raw API calls with LineupService, reduced _prepare_next_play() from ~50 lines to ~15 lines - app/core/substitution_manager.py: Updated pinch_hit(), defensive_replace(), change_pitcher() to use lineup_service.get_sba_player_data() - app/models/game_models.py: Added player_name/player_image to LineupPlayerState - app/services/__init__.py: Exported new LineupService components - app/websocket/handlers.py: Enhanced lineup state handling Frontend - SBA League: - components/Game/CurrentSituation.vue: Restored player images with fallback badges (P/B letters) for both mobile and desktop layouts - components/Game/GameBoard.vue: Enhanced game board visualization - composables/useGameActions.ts: Updated game action handling - composables/useWebSocket.ts: Improved WebSocket state management - pages/games/[id].vue: Enhanced game page with better state handling - types/game.ts: Updated type definitions - types/websocket.ts: Added WebSocket type support Architecture Improvement: All SBA player data fetching now goes through LineupService: - Lineup creation: add_sba_player_to_lineup() - Lineup loading: load_team_lineup_with_player_data() - Substitutions: get_sba_player_data() All 739 unit tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
373 lines
13 KiB
Vue
Executable File
373 lines
13 KiB
Vue
Executable File
<template>
|
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
<!-- Sticky ScoreBoard Header -->
|
|
<div class="sticky top-0 z-20">
|
|
<ScoreBoard
|
|
:home-score="gameState?.home_score"
|
|
:away-score="gameState?.away_score"
|
|
:inning="gameState?.inning"
|
|
:half="gameState?.half"
|
|
:balls="gameState?.balls"
|
|
:strikes="gameState?.strikes"
|
|
:outs="gameState?.outs"
|
|
:runners="runnersState"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Main Game Container -->
|
|
<div class="container mx-auto px-4 py-6 lg:py-8">
|
|
<!-- Connection Status Banner -->
|
|
<div
|
|
v-if="!isConnected"
|
|
class="mb-4 bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-lg"
|
|
>
|
|
<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"></circle>
|
|
<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"></path>
|
|
</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>
|
|
</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 (if game is active) -->
|
|
<DecisionPanel
|
|
v-if="gameState?.status === 'active'"
|
|
: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"
|
|
/>
|
|
</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 -->
|
|
<DecisionPanel
|
|
v-if="gameState?.status === 'active'"
|
|
: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"
|
|
/>
|
|
</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-24">
|
|
<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">
|
|
<div class="w-16 h-16 mx-auto mb-4 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
<p class="text-gray-900 dark:text-white font-semibold">Loading game...</p>
|
|
</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
|
|
@click="navigateTo('/games')"
|
|
class="px-6 py-3 bg-primary hover:bg-blue-700 text-white rounded-lg font-semibold transition shadow-md"
|
|
>
|
|
Back to Games
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useGameStore } from '~/store/game'
|
|
import { useAuthStore } from '~/store/auth'
|
|
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 type { DefensiveDecision, OffensiveDecision } from '~/types/game'
|
|
|
|
definePageMeta({
|
|
layout: 'game',
|
|
// middleware: ['auth'], // Temporarily disabled for WebSocket testing
|
|
})
|
|
|
|
const route = useRoute()
|
|
const gameStore = useGameStore()
|
|
const authStore = useAuthStore()
|
|
|
|
// Initialize auth from localStorage (for testing without OAuth)
|
|
authStore.initializeAuth()
|
|
|
|
// Get game ID from route
|
|
const gameId = computed(() => route.params.id as string)
|
|
|
|
// WebSocket connection
|
|
const { socket, isConnected, connectionError, connect } = useWebSocket()
|
|
|
|
// 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
|
|
const gameState = computed(() => gameStore.gameState)
|
|
const playHistory = computed(() => gameStore.playHistory)
|
|
const canRollDice = computed(() => gameStore.canRollDice)
|
|
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 isLoading = ref(true)
|
|
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
|
|
|
|
// 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(() => {
|
|
// TODO: Implement actual team ownership logic
|
|
// For now, assume it's always the player's turn for testing
|
|
return true
|
|
})
|
|
|
|
const decisionPhase = computed(() => {
|
|
if (needsDefensiveDecision.value) return 'defensive'
|
|
if (needsOffensiveDecision.value) return 'offensive'
|
|
return 'idle'
|
|
})
|
|
|
|
// Methods
|
|
const handleRollDice = () => {
|
|
if (canRollDice.value) {
|
|
actions.rollDice()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
console.log('[Game Page] Mounted for game:', gameId.value)
|
|
|
|
// Connect to WebSocket
|
|
if (!isConnected.value) {
|
|
connect()
|
|
}
|
|
|
|
// 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 })
|
|
})
|
|
|
|
// Watch for game state to load lineups
|
|
watch(gameState, (state) => {
|
|
if (state && state.home_team_id && state.away_team_id) {
|
|
// 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 })
|
|
|
|
onUnmounted(() => {
|
|
console.log('[Game Page] Unmounted - Leaving game')
|
|
|
|
// Leave game room
|
|
actions.leaveGame()
|
|
|
|
// Reset game store
|
|
gameStore.resetGame()
|
|
})
|
|
|
|
// Watch for connection errors
|
|
watch(connectionError, (error) => {
|
|
if (error) {
|
|
console.error('[Game Page] Connection error:', error)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Additional styling if needed */
|
|
</style>
|